Advertisement
Guest User

myasuswrt.py

a guest
Dec 8th, 2018
622
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 13.15 KB | None | 0 0
  1. """
  2. Support for ASUSWRT routers.
  3. For more details about this platform, please refer to the documentation at
  4. https://home-assistant.io/components/device_tracker.asuswrt/
  5. """
  6. import logging
  7. import re
  8. import socket
  9. import telnetlib
  10. from collections import namedtuple
  11.  
  12. import voluptuous as vol
  13.  
  14. import homeassistant.helpers.config_validation as cv
  15. from homeassistant.components.device_tracker import (
  16.     DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
  17. from homeassistant.const import (
  18.     CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_MODE,
  19.     CONF_PROTOCOL)
  20.  
  21. REQUIREMENTS = ['pexpect==4.0.1']
  22.  
  23. _LOGGER = logging.getLogger(__name__)
  24.  
  25. CONF_PUB_KEY = 'pub_key'
  26. CONF_SSH_KEY = 'ssh_key'
  27. DEFAULT_SSH_PORT = 22
  28. SECRET_GROUP = 'Password or SSH Key'
  29.  
  30. PLATFORM_SCHEMA = vol.All(
  31.     cv.has_at_least_one_key(CONF_PASSWORD, CONF_PUB_KEY, CONF_SSH_KEY),
  32.     PLATFORM_SCHEMA.extend({
  33.         vol.Required(CONF_HOST): cv.string,
  34.         vol.Required(CONF_USERNAME): cv.string,
  35.         vol.Optional(CONF_PROTOCOL, default='ssh'): vol.In(['ssh', 'telnet']),
  36.         vol.Optional(CONF_MODE, default='router'): vol.In(['router', 'ap']),
  37.         vol.Optional(CONF_PORT, default=DEFAULT_SSH_PORT): cv.port,
  38.         vol.Exclusive(CONF_PASSWORD, SECRET_GROUP): cv.string,
  39.         vol.Exclusive(CONF_SSH_KEY, SECRET_GROUP): cv.isfile,
  40.         vol.Exclusive(CONF_PUB_KEY, SECRET_GROUP): cv.isfile
  41.     }))
  42.  
  43.  
  44. _LEASES_CMD = 'cat /var/lib/misc/dnsmasq.leases'
  45. _LEASES_REGEX = re.compile(
  46.     r'\w+\s' +
  47.     r'(?P<mac>(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s' +
  48.     r'(?P<ip>([0-9]{1,3}[\.]){3}[0-9]{1,3})\s' +
  49.     r'(?P<host>([^\s]+))')
  50.  
  51. # Command to get both 5GHz and 2.4GHz clients
  52. _WL_CMD = 'for dev in `nvram get wl_ifnames`; do wl -i $dev assoclist; done'
  53. _WL_REGEX = re.compile(
  54.     r'\w+\s' +
  55.     r'(?P<mac>(([0-9A-F]{2}[:-]){5}([0-9A-F]{2})))')
  56.  
  57. _IP_NEIGH_CMD = 'ip neigh'
  58. _IP_NEIGH_REGEX = re.compile(
  59.     r'(?P<ip>([0-9]{1,3}[\.]){3}[0-9]{1,3}|'
  60.     r'([0-9a-fA-F]{1,4}:){1,7}[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{1,4}){1,7})\s'
  61.     r'\w+\s'
  62.     r'\w+\s'
  63.     r'(\w+\s(?P<mac>(([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))))?\s'
  64.     r'\s?(router)?'
  65.     r'\s?(nud)?'
  66.     r'(?P<status>(\w+))')
  67.  
  68. _ARP_CMD = 'arp -n'
  69. _ARP_REGEX = re.compile(
  70.     r'.+\s' +
  71.     r'\((?P<ip>([0-9]{1,3}[\.]){3}[0-9]{1,3})\)\s' +
  72.     r'.+\s' +
  73.     r'(?P<mac>(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))' +
  74.     r'\s' +
  75.     r'.*')
  76.  
  77.  
  78. # pylint: disable=unused-argument
  79. def get_scanner(hass, config):
  80.     """Validate the configuration and return an ASUS-WRT scanner."""
  81.     scanner = AsusWrtDeviceScanner(config[DOMAIN])
  82.  
  83.     return scanner if scanner.success_init else None
  84.  
  85. Device = namedtuple('Device', ['mac', 'ip', 'name'])
  86.  
  87.  
  88. class AsusWrtDeviceScanner(DeviceScanner):
  89.     """This class queries a router running ASUSWRT firmware."""
  90.  
  91.     # Eighth attribute needed for mode (AP mode vs router mode)
  92.     def __init__(self, config):
  93.         """Initialize the scanner."""
  94.         self.host = config[CONF_HOST]
  95.         self.username = config[CONF_USERNAME]
  96.         self.password = config.get(CONF_PASSWORD, '')
  97.         self.ssh_key = config.get('ssh_key', config.get('pub_key', ''))
  98.         self.protocol = config[CONF_PROTOCOL]
  99.         self.mode = config[CONF_MODE]
  100.         self.port = config[CONF_PORT]
  101.  
  102.         if self.protocol == 'ssh':
  103.             self.connection = SshConnection(
  104.                 self.host, self.port, self.username, self.password,
  105.                 self.ssh_key)
  106.         else:
  107.             self.connection = TelnetConnection(
  108.                 self.host, self.port, self.username, self.password)
  109.  
  110.         self.last_results = {}
  111.  
  112.         # Test the router is accessible.
  113.         data = self.get_asuswrt_data()
  114.         self.success_init = data is not None
  115.  
  116.     def scan_devices(self):
  117.         """Scan for new devices and return a list with found device IDs."""
  118.         self._update_info()
  119.         return list(self.last_results.keys())
  120.  
  121.     def get_device_name(self, device):
  122.         """Return the name of the given device or None if we don't know."""
  123.         if device not in self.last_results:
  124.             return None
  125.         return self.last_results[device].name
  126.  
  127.     def _update_info(self):
  128.         """Ensure the information from the ASUSWRT router is up to date.
  129.        Return boolean if scanning successful.
  130.        """
  131.         if not self.success_init:
  132.             return False
  133.  
  134.         _LOGGER.info('Checking Devices')
  135.         data = self.get_asuswrt_data()
  136.         if not data:
  137.             return False
  138.  
  139.         self.last_results = data
  140.         return True
  141.  
  142.     def get_asuswrt_data(self):
  143.         """Retrieve data from ASUSWRT.
  144.        Calls various commands on the router and returns the superset of all
  145.        responses. Some commands will not work on some routers.
  146.        """
  147.         devices = {}
  148.         devices.update(self._get_wl())
  149.         devices.update(self._get_arp())
  150.         devices.update(self._get_neigh(devices))
  151.         if not self.mode == 'ap':
  152.             devices.update(self._get_leases(devices))
  153.  
  154.         ret_devices = {}
  155.         for key in devices:
  156.             if self.mode == 'ap' or devices[key].ip is not None:
  157.                 ret_devices[key] = devices[key]
  158.         return ret_devices
  159.  
  160.     def _get_wl(self):
  161.         lines = self.connection.run_command(_WL_CMD)
  162.         if not lines:
  163.             return {}
  164.         result = self._parse_lines(lines, _WL_REGEX)
  165.         devices = {}
  166.         for device in result:
  167.             mac = device['mac'].upper()
  168.             devices[mac] = Device(mac, None, None)
  169.         return devices
  170.  
  171.     def _get_leases(self, cur_devices):
  172.         lines = self.connection.run_command(_LEASES_CMD)
  173.         if not lines:
  174.             return {}
  175.         lines = [line for line in lines if not line.startswith('duid ')]
  176.         result = self._parse_lines(lines, _LEASES_REGEX)
  177.         devices = {}
  178.         for device in result:
  179.             # For leases where the client doesn't set a hostname, ensure it
  180.             # is blank and not '*', which breaks entity_id down the line.
  181.             host = device['host']
  182.             if host == '*':
  183.                 host = ''
  184.             mac = device['mac'].upper()
  185.             if mac in cur_devices:
  186.                 devices[mac] = Device(mac, device['ip'], host)
  187.         return devices
  188.  
  189.     def _get_neigh(self, cur_devices):
  190.         lines = self.connection.run_command(_IP_NEIGH_CMD)
  191.         if not lines:
  192.             return {}
  193.         result = self._parse_lines(lines, _IP_NEIGH_REGEX)
  194.         devices = {}
  195.         for device in result:
  196.             status = device['status']
  197.             if status is None or status.upper() != 'REACHABLE':
  198.                 continue
  199.             if device['mac'] is not None:
  200.                 mac = device['mac'].upper()
  201.                 old_device = cur_devices.get(mac)
  202.                 old_ip = old_device.ip if old_device else None
  203.                 devices[mac] = Device(mac, device.get('ip', old_ip), None)
  204.         return devices
  205.  
  206.     def _get_arp(self):
  207.         lines = self.connection.run_command(_ARP_CMD)
  208.         if not lines:
  209.             return {}
  210.         result = self._parse_lines(lines, _ARP_REGEX)
  211.         devices = {}
  212.         for device in result:
  213.             if device['mac'] is not None:
  214.                 mac = device['mac'].upper()
  215.                 devices[mac] = Device(mac, device['ip'], None)
  216.         return devices
  217.    
  218.     def _parse_lines(self, lines, regex):
  219.         """Parse the lines using the given regular expression.
  220.        If a line can't be parsed it is logged and skipped in the output.
  221.        """
  222.         results = []
  223.         for line in lines:
  224.             if "<incomplete>" in line:
  225.                 continue
  226.             match = regex.search(line)
  227.             if not match:
  228.                 _LOGGER.debug("Could not parse row: %s", line)
  229.                 _LOGGER.debug("Regex: %s", regex.pattern)
  230.                 self.connection.disconnect()
  231.                 continue
  232.             results.append(match.groupdict())
  233.         return results
  234.  
  235.  
  236. class _Connection:
  237.     def __init__(self):
  238.         self._connected = False
  239.  
  240.     @property
  241.     def connected(self):
  242.         """Return connection state."""
  243.         return self._connected
  244.  
  245.     def connect(self):
  246.         """Mark current connection state as connected."""
  247.         self._connected = True
  248.  
  249.     def disconnect(self):
  250.         """Mark current connection state as disconnected."""
  251.         self._connected = False
  252.  
  253.  
  254. class SshConnection(_Connection):
  255.     """Maintains an SSH connection to an ASUS-WRT router."""
  256.  
  257.     def __init__(self, host, port, username, password, ssh_key):
  258.         """Initialize the SSH connection properties."""
  259.         super().__init__()
  260.  
  261.         self._ssh = None
  262.         self._host = host
  263.         self._port = port
  264.         self._username = username
  265.         self._password = password
  266.         self._ssh_key = ssh_key
  267.  
  268.     def run_command(self, command):
  269.         """Run commands through an SSH connection.
  270.        Connect to the SSH server if not currently connected, otherwise
  271.        use the existing connection.
  272.        """
  273.         from pexpect import pxssh, exceptions
  274.  
  275.         try:
  276.             if not self.connected:
  277.                 self.connect()
  278.             self._ssh.sendline(command)
  279.             self._ssh.prompt()
  280.             lines = self._ssh.before.split(b'\n')[1:-1]
  281.             return [line.decode('utf-8') for line in lines]
  282.         except exceptions.EOF as err:
  283.             _LOGGER.error("Connection refused. %s", self._ssh.before)
  284.             self.disconnect()
  285.             return None
  286.         except pxssh.ExceptionPxssh as err:
  287.             _LOGGER.error("Unexpected SSH error: %s", err)
  288.             self.disconnect()
  289.             return None
  290.         except AssertionError as err:
  291.             _LOGGER.error("Connection to router unavailable: %s", err)
  292.             self.disconnect()
  293.             return None
  294.         except:
  295.             _LOGGER.error("run_command -> Unexpected error: %s", sys.exc_info()[0])
  296.             self.disconnect()
  297.             return None
  298.  
  299.     def connect(self):
  300.         """Connect to the ASUS-WRT SSH server."""
  301.         from pexpect import pxssh
  302.  
  303.         self._ssh = pxssh.pxssh()
  304.         if self._ssh_key:
  305.             self._ssh.login(self._host, self._username, quiet=False,
  306.                             ssh_key=self._ssh_key, port=self._port)
  307.         else:
  308.             self._ssh.login(self._host, self._username, quiet=False,
  309.                             password=self._password, port=self._port)
  310.         _LOGGER.debug("Asuswrt connected")
  311.  
  312.         super().connect()
  313.  
  314.     def disconnect(self):   \
  315.             # pylint: disable=broad-except
  316.         """Disconnect the current SSH connection."""
  317.         try:
  318.             self._ssh.logout()
  319.         except Exception:
  320.             pass
  321.         finally:
  322.             self._ssh = None
  323.         _LOGGER.debug("Asuswrt disconnected")
  324.  
  325.         super().disconnect()
  326.  
  327.  
  328. class TelnetConnection(_Connection):
  329.     """Maintains a Telnet connection to an ASUS-WRT router."""
  330.  
  331.     def __init__(self, host, port, username, password):
  332.         """Initialize the Telnet connection properties."""
  333.         super().__init__()
  334.  
  335.         self._telnet = None
  336.         self._host = host
  337.         self._port = port
  338.         self._username = username
  339.         self._password = password
  340.         self._prompt_string = None
  341.  
  342.     def run_command(self, command):
  343.         """Run a command through a Telnet connection.
  344.        Connect to the Telnet server if not currently connected, otherwise
  345.        use the existing connection.
  346.        """
  347.         try:
  348.             if not self.connected:
  349.                 self.connect()
  350.             self._telnet.write('{}\n'.format(command).encode('ascii'))
  351.             data = (self._telnet.read_until(self._prompt_string).
  352.                     split(b'\n')[1:-1])
  353.             return [line.decode('utf-8') for line in data]
  354.         except EOFError:
  355.             _LOGGER.error("Unexpected response from router")
  356.             self.disconnect()
  357.             return None
  358.         except ConnectionRefusedError:
  359.             _LOGGER.error("Connection refused by router. Telnet enabled?")
  360.             self.disconnect()
  361.             return None
  362.         except socket.gaierror as exc:
  363.             _LOGGER.error("Socket exception: %s", exc)
  364.             self.disconnect()
  365.             return None
  366.         except OSError as exc:
  367.             _LOGGER.error("OSError: %s", exc)
  368.             self.disconnect()
  369.             return None
  370.  
  371.     def connect(self):
  372.         """Connect to the ASUS-WRT Telnet server."""
  373.         self._telnet = telnetlib.Telnet(self._host)
  374.         self._telnet.read_until(b'login: ')
  375.         self._telnet.write((self._username + '\n').encode('ascii'))
  376.         self._telnet.read_until(b'Password: ')
  377.         self._telnet.write((self._password + '\n').encode('ascii'))
  378.         self._prompt_string = self._telnet.read_until(b'#').split(b'\n')[-1]
  379.  
  380.         super().connect()
  381.  
  382.     def disconnect(self):   \
  383.             # pylint: disable=broad-except
  384.         """Disconnect the current Telnet connection."""
  385.         try:
  386.             self._telnet.write('exit\n'.encode('ascii'))
  387.         except Exception:
  388.             pass
  389.  
  390.         super().disconnect()
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement