"""Client to communicate with Sagemcom F@st internal APIs.""" from __future__ import annotations import asyncio import hashlib import json import math import random import urllib.parse import humps from types import TracebackType from typing import Dict, List, Optional, Type from aiohttp import ClientSession, ClientTimeout from aiohttp.connector import TCPConnector from . import __version__ from .const import ( API_ENDPOINT, DEFAULT_TIMEOUT, DEFAULT_USER_AGENT, XMO_ACCESS_RESTRICTION_ERR, XMO_AUTHENTICATION_ERR, XMO_MAX_SESSION_COUNT_ERR, XMO_NO_ERR, XMO_NON_WRITABLE_PARAMETER_ERR, XMO_REQUEST_ACTION_ERR, XMO_REQUEST_NO_ERR, XMO_UNKNOWN_PATH_ERR, ) from .enums import EncryptionMethod from .exceptions import ( AccessRestrictionException, AuthenticationException, BadRequestException, LoginTimeoutException, MaximumSessionCountException, NonWritableParameterException, UnauthorizedException, UnknownException, UnknownPathException, ) from .models import Device, DeviceInfo, PortMapping class SagemcomClient: """Client to communicate with the Sagemcom API.""" def __init__( self, host, username, password, authentication_method, session: ClientSession = None, ssl=False, verify_ssl=True, ): """ Create a SagemCom client. :param host: the host of your Sagemcom router :param username: the username for your Sagemcom router :param password: the password for your Sagemcom router :param authentication_method: the auth method of your Sagemcom router :param session: use a custom session, for example to configure the timeout """ self.host = host self.username = username self.authentication_method = authentication_method self._password_hash = self.__generate_hash(password) self.protocol = "https" if ssl else "http" self._current_nonce = None self._server_nonce = "" self._session_id = 0 self._request_id = -1 self.session = ( session if session else ClientSession( headers={"User-Agent": f"{DEFAULT_USER_AGENT}/{__version__}"}, timeout=ClientTimeout(DEFAULT_TIMEOUT), connector=TCPConnector(ssl=verify_ssl), ) ) async def __aenter__(self) -> SagemcomClient: """TODO.""" return self async def __aexit__( self, exc_type: Optional[Type[BaseException]], exc_value: Optional[BaseException], traceback: Optional[TracebackType], ) -> None: """Close session on exit.""" await self.close() async def close(self) -> None: """Close the websession.""" await self.session.close() def __generate_nonce(self): """Generate pseudo random number (nonce) to avoid replay attacks.""" self._current_nonce = math.floor(random.randrange(0, 1) * 500000) def __generate_request_id(self): """Generate sequential request ID.""" self._request_id += 1 def __generate_hash(self, value, authentication_method=None): """Hash value with selected encryption method and return HEX value.""" auth_method = authentication_method or self.authentication_method bytes_object = bytes(value, encoding="utf-8") if auth_method == EncryptionMethod.MD5: return hashlib.md5(bytes_object).hexdigest() if auth_method == EncryptionMethod.SHA512: return hashlib.sha512(bytes_object).hexdigest() return value def __get_credential_hash(self): """Build credential hash.""" return self.__generate_hash( self.username + ":" + self._server_nonce + ":" + self._password_hash ) def __generate_auth_key(self): """Build auth key.""" credential_hash = self.__get_credential_hash() auth_string = f"{credential_hash}:{self._request_id}:{self._current_nonce}:JSON:{API_ENDPOINT}" self._auth_key = self.__generate_hash(auth_string) def __get_response_error(self, response): """Retrieve response error from result.""" try: value = response["reply"]["error"] except KeyError: value = None return value def __get_response(self, response, index=0): """Retrieve response from result.""" try: value = response["reply"]["actions"][index]["callbacks"][0]["parameters"] except KeyError: value = None return value def __get_response_value(self, response, index=0): """Retrieve response value from value.""" try: value = self.__get_response(response, index)["value"] except KeyError: value = None # Rewrite result to snake_case value = humps.decamelize(value) return value async def __api_request_async(self, actions, priority=False): """Build request to the internal JSON-req API.""" self.__generate_request_id() self.__generate_nonce() self.__generate_auth_key() api_host = f"{self.protocol}://{self.host}{API_ENDPOINT}" payload = { "request": { "id": self._request_id, "session-id": str(self._session_id), "priority": priority, "actions": actions, "cnonce": self._current_nonce, "auth-key": self._auth_key, } } async with self.session.post( api_host, data="req=" + json.dumps(payload, separators=(",", ":")) ) as response: if response.status == 400: result = await response.text() raise BadRequestException(result) if response.status != 200: result = await response.text() raise UnknownException(result) if response.status == 200: result = await response.json() error = self.__get_response_error(result) # No errors if ( error["description"] == XMO_REQUEST_NO_ERR or error["description"] == "Ok" # NOQA: W503 ): return result # Error in one of the actions if error["description"] == XMO_REQUEST_ACTION_ERR: # TODO How to support multiple actions + error handling? actions = result["reply"]["actions"] for action in actions: action_error = action["error"] action_error_description = action_error["description"] if action_error_description == XMO_NO_ERR: continue if action_error_description == XMO_AUTHENTICATION_ERR: raise AuthenticationException(action_error) if action_error_description == XMO_ACCESS_RESTRICTION_ERR: raise AccessRestrictionException(action_error) if action_error_description == XMO_NON_WRITABLE_PARAMETER_ERR: raise NonWritableParameterException(action_error) if action_error_description == XMO_UNKNOWN_PATH_ERR: raise UnknownPathException(action_error) if action_error_description == XMO_MAX_SESSION_COUNT_ERR: raise MaximumSessionCountException(action_error) raise UnknownException(action_error) return result async def login(self): """TODO.""" actions = { "method": "logIn", "parameters": { "user": self.username, "persistent": True, "session-options": { "nss": [{"name": "gtw", "uri": "http://sagemcom.com/gateway-data"}], "language": "ident", "context-flags": {"get-content-name": True, "local-time": True}, "capability-depth": 2, "capability-flags": { "name": True, "default-value": False, "restriction": True, "description": False, }, "time-format": "ISO_8601", "write-only-string": "_XMO_WRITE_ONLY_", "undefined-write-only-string": "_XMO_UNDEFINED_WRITE_ONLY_", }, }, } try: response = await self.__api_request_async([actions], True) except asyncio.TimeoutError as exception: raise LoginTimeoutException( "Request timed-out. This is mainly due to using the wrong encryption method." ) from exception data = self.__get_response(response) if data["id"] is not None and data["nonce"] is not None: self._session_id = data["id"] self._server_nonce = data["nonce"] return True else: raise UnauthorizedException(data) async def logout(self): """Log out of the Sagemcom F@st device.""" actions = {"id": 0, "method": "logOut"} await self.__api_request_async([actions], False) self._session_id = -1 self._server_nonce = "" self._request_id = -1 async def get_value_by_xpath( self, xpath: str, options: Optional[Dict] = {} ) -> Dict: """ Retrieve raw value from router using XPath. :param xpath: path expression :param options: optional options """ actions = { "id": 0, "method": "getValue", "xpath": urllib.parse.quote(xpath), "options": options, } response = await self.__api_request_async([actions], False) data = self.__get_response_value(response) return data async def get_values_by_xpaths(self, xpaths, options: Optional[Dict] = {}) -> Dict: """ Retrieve raw values from router using XPath. :param xpaths: Dict of key to xpath expression :param options: optional options """ actions = [ { "id": i, "method": "getValue", "xpath": urllib.parse.quote(xpath), "options": options, } for i, xpath in enumerate(xpaths.values()) ] response = await self.__api_request_async(actions, False) values = [self.__get_response_value(response, i) for i in range(len(xpaths))] data = dict(zip(xpaths.keys(), values)) return data async def set_value_by_xpath( self, xpath: str, value: str, options: Optional[Dict] = {} ) -> Dict: """ Retrieve raw value from router using XPath. :param xpath: path expression :param value: value :param options: optional options """ actions = { "id": 0, "method": "setValue", "xpath": xpath, "parameters": {"value": str(value)}, "options": options, } response = await self.__api_request_async([actions], False) return response async def get_device_info(self) -> DeviceInfo: """Retrieve information about Sagemcom F@st device.""" try: data = await self.get_value_by_xpath("Device/DeviceInfo") return DeviceInfo(**data.get("device_info")) except UnknownPathException: data = await self.get_values_by_xpaths( { "mac_address": "Device/DeviceInfo/MACAddress", "model_name": "Device/DeviceInfo/ModelNumber", "model_number": "Device/DeviceInfo/ProductClass", "product_class": "Device/DeviceInfo/ProductClass", "serial_number": "Device/DeviceInfo/SerialNumber", "software_version": "Device/DeviceInfo/SoftwareVersion", } ) data["manufacturer"] = "Sagemcom" return DeviceInfo(**data) async def get_hosts(self, only_active: Optional[bool] = False) -> List[Device]: """Retrieve hosts connected to Sagemcom F@st device.""" data = await self.get_value_by_xpath("Device/Hosts/Hosts") devices = [Device(**d) for d in data] if only_active: active_devices = [d for d in devices if d.active is True] return active_devices return devices async def get_port_mappings(self) -> List[PortMapping]: """Retrieve configured Port Mappings on Sagemcom F@st device.""" data = await self.get_value_by_xpath("Device/NAT/PortMappings") port_mappings = [PortMapping(**p) for p in data] return port_mappings async def reboot(self): """Reboot Sagemcom F@st device.""" action = { "method": "reboot", "xpath": "Device", "parameters": {"source": "GUI"}, } response = await self.__api_request_async([action], False) data = self.__get_response_value(response) return data