diff --git a/.travis.yml b/.travis.yml index 242bce6..dcad821 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,10 +6,10 @@ python: "3.7" before_install: - pip install pylint coverage - pip install -e . -# - pylint txupnp +# - pylint aioupnp script: - - HOME=/tmp coverage run --source=txupnp -m twisted.trial tests + - HOME=/tmp coverage run --source=aioupnp -m unittest -v after_success: - bash <(curl -s https://codecov.io/bash) diff --git a/README.md b/README.md index 52544dd..3499896 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,27 @@ [![codecov](https://codecov.io/gh/lbryio/txupnp/branch/master/graph/badge.svg)](https://codecov.io/gh/lbryio/txupnp) -# UPnP for Twisted +# UPnP for asyncio -`txupnp` is a python 3 library to interact with UPnP gateways using `twisted` +`aioupnp` is a python 3 library and command line tool to interact with UPnP gateways using asyncio. `aioupnp` requires the `netifaces` module. ## Installation ``` -pip install --upgrade txupnp +pip install --upgrade aioupnp ``` ## Usage ``` -usage: txupnp-cli [-h] [--debug_logging] [--include_igd_xml] command +usage: txupnp [-h] [--debug_logging=] [--interface=] + [--gateway_address=] + [--lan_address=] [--timeout=] + [--service=] + command [--=]... -positional arguments: - command debug_device | list_mappings | get_external_ip | - add_mapping | delete_mapping +commands: add_port_mapping | delete_port_mapping | get_external_ip | get_next_mapping | get_port_mapping_by_index | get_redirects | get_soap_commands | get_specific_port_mapping | m_search -optional arguments: - -h, --help show this help message and exit - --debug_logging - --include_igd_xml +for help with a specific command: txupnp help ``` diff --git a/txupnp/__init__.py b/aioupnp/__init__.py similarity index 86% rename from txupnp/__init__.py rename to aioupnp/__init__.py index 8db0cad..7eaba53 100644 --- a/txupnp/__init__.py +++ b/aioupnp/__init__.py @@ -1,5 +1,5 @@ __version__ = "0.0.1a11" -__name__ = "txupnp" +__name__ = "aioupnp" __author__ = "Jack Robison" __maintainer__ = "Jack Robison" __license__ = "MIT" diff --git a/aioupnp/__main__.py b/aioupnp/__main__.py new file mode 100644 index 0000000..9aba6cb --- /dev/null +++ b/aioupnp/__main__.py @@ -0,0 +1,90 @@ +import logging +import sys +from aioupnp.upnp import UPnP +from aioupnp.constants import UPNP_ORG_IGD + + +log = logging.getLogger("aioupnp") +handler = logging.StreamHandler() +handler.setFormatter(logging.Formatter('%(asctime)-15s-%(filename)s:%(lineno)s->%(message)s')) +log.addHandler(handler) +log.setLevel(logging.WARNING) + + +def get_help(command): + fn = getattr(UPnP, command) + params = command + " " + " ".join(["[--%s=<%s>]" % (k, k) for k in fn.__annotations__ if k != 'return']) + return \ + "usage: aioupnp [--debug_logging=] [--interface=]\n" \ + " [--gateway_address=]\n" \ + " [--lan_address=] [--timeout=]\n" \ + " [--service=]\n" \ + " %s\n" % params + + +def main(): + commands = [n for n in dir(UPnP) if hasattr(getattr(UPnP, n, None), "_cli")] + help_str = " | ".join(commands) + usage = \ + "usage: aioupnp [-h] [--debug_logging=] [--interface=]\n" \ + " [--gateway_address=]\n" \ + " [--lan_address=] [--timeout=]\n" \ + " [--service=]\n" \ + " command [--=]...\n" \ + "\n" \ + "commands: %s\n\nfor help with a specific command: aioupnp help " % help_str + + args = sys.argv[1:] + if args[0] in ['help', '-h', '--help']: + if len(args) > 1: + if args[1] in commands: + sys.exit(get_help(args[1])) + sys.exit(print(usage)) + + defaults = { + 'debug_logging': False, + 'interface': 'default', + 'gateway_address': '', + 'lan_address': '', + 'timeout': 1, + 'service': UPNP_ORG_IGD, + 'return_as_json': True + } + + options = {} + command = None + for arg in args: + if arg.startswith("--"): + k, v = arg.split("=") + k = k.lstrip('--') + options[k] = v + else: + command = arg + break + if not command: + print("no command given") + sys.exit(print(usage)) + kwargs = {} + for arg in args[len(options)+1:]: + if arg.startswith("--"): + k, v = arg.split("=") + k = k.lstrip('--') + kwargs[k] = v + else: + break + for k, v in defaults.items(): + if k not in options: + options[k] = v + + if options.pop('debug_logging'): + log.setLevel(logging.DEBUG) + + UPnP.run_cli( + command.replace('-', '_'), options.pop('lan_address'), options.pop('gateway_address'), + options.pop('timeout'), options.pop('service'), options.pop('interface'), + kwargs + ) + + +if __name__ == "__main__": + main() diff --git a/txupnp/commands.py b/aioupnp/commands.py similarity index 62% rename from txupnp/commands.py rename to aioupnp/commands.py index 92eb737..2541c0a 100644 --- a/txupnp/commands.py +++ b/aioupnp/commands.py @@ -1,27 +1,25 @@ -from txupnp.util import return_types, none_or_str, none +def none_or_str(x): + return None if not x or x == 'None' else str(x) -class SCPDCommands: # TODO use type annotations +class SCPDCommands: def debug_commands(self) -> dict: raise NotImplementedError() @staticmethod - @return_types(none) - def AddPortMapping(NewRemoteHost: str, NewExternalPort: int, NewProtocol: str, NewInternalPort: int, + async def AddPortMapping(NewRemoteHost: str, NewExternalPort: int, NewProtocol: str, NewInternalPort: int, NewInternalClient: str, NewEnabled: bool, NewPortMappingDescription: str, NewLeaseDuration: str = '') -> None: """Returns None""" raise NotImplementedError() @staticmethod - @return_types(bool, bool) - def GetNATRSIPStatus() -> (bool, bool): + async def GetNATRSIPStatus() -> (bool, bool): """Returns (NewRSIPAvailable, NewNATEnabled)""" raise NotImplementedError() @staticmethod - @return_types(none_or_str, int, str, int, str, bool, str, int) - def GetGenericPortMappingEntry(NewPortMappingIndex) -> (none_or_str, int, str, int, str, bool, str, int): + async def GetGenericPortMappingEntry(NewPortMappingIndex: int) -> (none_or_str, int, str, int, str, bool, str, int): """ Returns (NewRemoteHost, NewExternalPort, NewProtocol, NewInternalPort, NewInternalClient, NewEnabled, NewPortMappingDescription, NewLeaseDuration) @@ -29,70 +27,62 @@ class SCPDCommands: # TODO use type annotations raise NotImplementedError() @staticmethod - @return_types(int, str, bool, str, int) - def GetSpecificPortMappingEntry(NewRemoteHost, NewExternalPort, NewProtocol) -> (int, str, bool, str, int): + async def GetSpecificPortMappingEntry(NewRemoteHost: str, NewExternalPort: int, NewProtocol: str) -> (int, str, bool, str, int): """Returns (NewInternalPort, NewInternalClient, NewEnabled, NewPortMappingDescription, NewLeaseDuration)""" raise NotImplementedError() @staticmethod - @return_types(none) - def SetConnectionType(NewConnectionType) -> None: + async def SetConnectionType(NewConnectionType: str) -> None: """Returns None""" raise NotImplementedError() @staticmethod - @return_types(str) - def GetExternalIPAddress() -> str: + async def GetExternalIPAddress() -> str: """Returns (NewExternalIPAddress)""" raise NotImplementedError() @staticmethod - @return_types(str, str) - def GetConnectionTypeInfo() -> (str, str): + async def GetConnectionTypeInfo() -> (str, str): """Returns (NewConnectionType, NewPossibleConnectionTypes)""" raise NotImplementedError() @staticmethod - @return_types(str, str, int) - def GetStatusInfo() -> (str, str, int): + async def GetStatusInfo() -> (str, str, int): """Returns (NewConnectionStatus, NewLastConnectionError, NewUptime)""" raise NotImplementedError() @staticmethod - @return_types(none) - def ForceTermination() -> None: + async def ForceTermination() -> None: """Returns None""" raise NotImplementedError() @staticmethod - @return_types(none) - def DeletePortMapping(NewRemoteHost, NewExternalPort, NewProtocol) -> None: + async def DeletePortMapping(NewRemoteHost: str, NewExternalPort: int, NewProtocol: str) -> None: """Returns None""" raise NotImplementedError() @staticmethod - @return_types(none) - def RequestConnection() -> None: + async def RequestConnection() -> None: """Returns None""" raise NotImplementedError() @staticmethod - def GetCommonLinkProperties(): + async def GetCommonLinkProperties(): """Returns (NewWANAccessType, NewLayer1UpstreamMaxBitRate, NewLayer1DownstreamMaxBitRate, NewPhysicalLinkStatus)""" raise NotImplementedError() @staticmethod - def GetTotalBytesSent(): + async def GetTotalBytesSent(): """Returns (NewTotalBytesSent)""" raise NotImplementedError() @staticmethod - def GetTotalBytesReceived(): + async def GetTotalBytesReceived(): """Returns (NewTotalBytesReceived)""" raise NotImplementedError() @staticmethod - def GetTotalPacketsSent(): + async def GetTotalPacketsSent(): """Returns (NewTotalPacketsSent)""" raise NotImplementedError() @@ -102,36 +92,33 @@ class SCPDCommands: # TODO use type annotations raise NotImplementedError() @staticmethod - def X_GetICSStatistics(): + async def X_GetICSStatistics() -> (int, int, int, int, str, str): """Returns (TotalBytesSent, TotalBytesReceived, TotalPacketsSent, TotalPacketsReceived, Layer1DownstreamMaxBitRate, Uptime)""" raise NotImplementedError() @staticmethod - def GetDefaultConnectionService(): + async def GetDefaultConnectionService(): """Returns (NewDefaultConnectionService)""" raise NotImplementedError() @staticmethod - def SetDefaultConnectionService(NewDefaultConnectionService) -> None: + async def SetDefaultConnectionService(NewDefaultConnectionService: str) -> None: """Returns (None)""" raise NotImplementedError() @staticmethod - @return_types(none) - def SetEnabledForInternet(NewEnabledForInternet) -> None: + async def SetEnabledForInternet(NewEnabledForInternet: bool) -> None: raise NotImplementedError() @staticmethod - @return_types(bool) - def GetEnabledForInternet() -> bool: + async def GetEnabledForInternet() -> bool: raise NotImplementedError() @staticmethod - def GetMaximumActiveConnections(NewActiveConnectionIndex): + async def GetMaximumActiveConnections(NewActiveConnectionIndex: int): raise NotImplementedError() @staticmethod - @return_types(str, str) - def GetActiveConnections() -> (str, str): + async def GetActiveConnections() -> (str, str): """Returns (NewActiveConnDeviceContainer, NewActiveConnectionServiceID""" raise NotImplementedError() diff --git a/txupnp/constants.py b/aioupnp/constants.py similarity index 93% rename from txupnp/constants.py rename to aioupnp/constants.py index 118d6d3..cdb0c52 100644 --- a/txupnp/constants.py +++ b/aioupnp/constants.py @@ -18,11 +18,6 @@ WAN_SCHEMA = 'urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1' LAYER_SCHEMA = 'urn:schemas-upnp-org:service:Layer3Forwarding:1' IP_SCHEMA = 'urn:schemas-upnp-org:service:WANIPConnection:1' -service_types = [ - UPNP_ORG_IGD, - # WIFI_ALLIANCE_ORG_IGD, -] - SSDP_IP_ADDRESS = '239.255.255.250' SSDP_PORT = 1900 SSDP_HOST = "%s:%i" % (SSDP_IP_ADDRESS, SSDP_PORT) diff --git a/aioupnp/device.py b/aioupnp/device.py new file mode 100644 index 0000000..c8d0716 --- /dev/null +++ b/aioupnp/device.py @@ -0,0 +1,95 @@ +import logging + +log = logging.getLogger(__name__) + + +class CaseInsensitive: + def __init__(self, **kwargs): + not_evaluated = {} + for k, v in kwargs.items(): + if k.startswith("_"): + not_evaluated[k] = v + continue + try: + getattr(self, k) + setattr(self, k, v) + except AttributeError as err: + not_evaluated[k] = v + if not_evaluated: + log.debug("%s did not apply kwargs: %s", self.__class__.__name__, not_evaluated) + + def _get_attr_name(self, case_insensitive: str) -> str: + for k, v in self.__dict__.items(): + if k.lower() == case_insensitive.lower(): + return k + + def __getattr__(self, item): + if item in self.__dict__: + return self.__dict__[item] + for k, v in self.__class__.__dict__.items(): + if k.lower() == item.lower(): + if k not in self.__dict__: + self.__dict__[k] = v + return v + raise AttributeError(item) + + def __setattr__(self, item, value): + if item in self.__dict__: + self.__dict__[item] = value + return + to_update = None + for k, v in self.__dict__.items(): + if k.lower() == item.lower(): + to_update = k + break + self.__dict__[to_update or item] = value + + def as_dict(self) -> dict: + return { + k: v for k, v in self.__dict__.items() if not k.startswith("_") and not callable(v) + } + + +class Service(CaseInsensitive): + serviceType = None + serviceId = None + controlURL = None + eventSubURL = None + SCPDURL = None + + +class Device(CaseInsensitive): + serviceList = None + deviceList = None + deviceType = None + friendlyName = None + manufacturer = None + manufacturerURL = None + modelDescription = None + modelName = None + modelNumber = None + modelURL = None + serialNumber = None + udn = None + upc = None + presentationURL = None + iconList = None + + def __init__(self, devices, services, **kwargs): + super(Device, self).__init__(**kwargs) + if self.serviceList and "service" in self.serviceList: + new_services = self.serviceList["service"] + if isinstance(new_services, dict): + new_services = [new_services] + services.extend([Service(**service) for service in new_services]) + if self.deviceList: + for kw in self.deviceList.values(): + if isinstance(kw, dict): + d = Device(devices, services, **kw) + devices.append(d) + elif isinstance(kw, list): + for _inner_kw in kw: + d = Device(devices, services, **_inner_kw) + devices.append(d) + else: + log.warning("failed to parse device:\n%s", kw) diff --git a/txupnp/fault.py b/aioupnp/fault.py similarity index 79% rename from txupnp/fault.py rename to aioupnp/fault.py index 70da4dd..6acb914 100644 --- a/txupnp/fault.py +++ b/aioupnp/fault.py @@ -1,5 +1,5 @@ -from txupnp.util import flatten_keys -from txupnp.constants import FAULT, CONTROL +from aioupnp.util import flatten_keys +from aioupnp.constants import FAULT, CONTROL class UPnPError(Exception): diff --git a/aioupnp/gateway.py b/aioupnp/gateway.py new file mode 100644 index 0000000..cf8aa91 --- /dev/null +++ b/aioupnp/gateway.py @@ -0,0 +1,181 @@ +import logging +from aioupnp.util import get_dict_val_case_insensitive, BASE_PORT_REGEX, BASE_ADDRESS_REGEX +from aioupnp.constants import SPEC_VERSION, UPNP_ORG_IGD, SERVICE +from aioupnp.commands import SCPDCommands +from aioupnp.device import Device, Service +from aioupnp.protocols.ssdp import m_search +from aioupnp.protocols.scpd import scpd_get +from aioupnp.protocols.soap import SCPDCommand +from aioupnp.util import flatten_keys + +log = logging.getLogger(__name__) + + +def get_action_list(element_dict: dict) -> list: # [(, [, ...], [ dict: + r = { + 'server': self.server.decode(), + 'urlBase': self.url_base, + 'location': self.location.decode(), + "specVersion": self.spec_version, + 'usn': self.usn.decode(), + 'urn': self.urn.decode(), + } + return r + + @property + def services(self) -> dict: + if not self._device: + return {} + return {service.serviceType: service for service in self._services} + + @property + def devices(self) -> dict: + if not self._device: + return {} + return {device.udn: device for device in self._devices} + + def get_service(self, service_type: str) -> Service: + for service in self._services: + if service.serviceType.lower() == service_type.lower(): + return service + + def debug_commands(self): + return { + 'available': self._registered_commands, + 'failed': self._unsupported_actions + } + + @classmethod + async def discover_gateway(cls, lan_address: str, gateway_address: str, timeout: int = 1, + service: str = UPNP_ORG_IGD): + datagram = await m_search(lan_address, gateway_address, timeout, service) + gateway = cls(**datagram.as_dict()) + await gateway.discover_commands() + return gateway + + async def discover_commands(self): + response = await scpd_get("/" + self.path.decode(), self.base_ip.decode(), self.port) + + self.spec_version = get_dict_val_case_insensitive(response, SPEC_VERSION) + self.url_base = get_dict_val_case_insensitive(response, "urlbase") + if not self.url_base: + self.url_base = self.base_address.decode() + if response: + self._device = Device( + self._devices, self._services, **get_dict_val_case_insensitive(response, "device") + ) + else: + self._device = Device(self._devices, self._services) + for service_type in self.services.keys(): + await self.register_commands(self.services[service_type]) + + async def register_commands(self, service: Service): + service_dict = await scpd_get(("" if service.SCPDURL.startswith("/") else "/") + service.SCPDURL, + self.base_ip.decode(), self.port) + if not service_dict: + return + + action_list = get_action_list(service_dict) + + for name, inputs, outputs in action_list: + try: + current = getattr(self.commands, name) + annotations = current.__annotations__ + return_types = annotations.get('return', None) + if return_types: + if not isinstance(return_types, tuple): + return_types = (return_types, ) + return_types = {r: t for r, t in zip(outputs, return_types)} + param_types = {} + for param_name, param_type in annotations.items(): + if param_name == "return": + continue + param_types[param_name] = param_type + command = SCPDCommand( + self.base_ip.decode(), self.port, service.controlURL, service.serviceType.encode(), + name, param_types, return_types, inputs, outputs) + setattr(command, "__doc__", current.__doc__) + setattr(self.commands, command.method, command) + + self._registered_commands[command.method] = service.serviceType + log.debug("registered %s::%s", service.serviceType, command.method) + except AttributeError: + s = self._unsupported_actions.get(service.serviceType, []) + s.append(name) + self._unsupported_actions[service.serviceType] = s + log.debug("available command for %s does not have a wrapper implemented: %s %s %s", + service.serviceType, name, inputs, outputs) diff --git a/aioupnp/protocols/__init__.py b/aioupnp/protocols/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aioupnp/protocols/multicast.py b/aioupnp/protocols/multicast.py new file mode 100644 index 0000000..abed963 --- /dev/null +++ b/aioupnp/protocols/multicast.py @@ -0,0 +1,46 @@ +import struct +import socket +from asyncio.protocols import DatagramProtocol +from asyncio.transports import DatagramTransport + + +class MulticastProtocol(DatagramProtocol): + @property + def socket(self) -> socket.socket: + return self.transport.get_extra_info('socket') + + def get_ttl(self) -> int: + return self.socket.getsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL) + + def set_ttl(self, ttl: int = 1) -> None: + self.socket.setsockopt( + socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, struct.pack('b', ttl) + ) + + def join_group(self, addr: str, interface: str) -> None: + addr = socket.inet_aton(addr) + interface = socket.inet_aton(interface) + self.socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, addr + interface) + + def leave_group(self, addr: str, interface: str) -> None: + addr = socket.inet_aton(addr) + interface = socket.inet_aton(interface) + self.socket.setsockopt(socket.IPPROTO_IP, socket.IP_DROP_MEMBERSHIP, addr + interface) + + def connection_made(self, transport: DatagramTransport) -> None: + self.transport = transport + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + @classmethod + def create_socket(cls, bind_address: str, multicast_address: str): + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind((bind_address, 0)) + sock.setsockopt( + socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, + socket.inet_aton(multicast_address) + socket.inet_aton(bind_address) + ) + sock.setblocking(False) + return sock + + diff --git a/aioupnp/protocols/scpd.py b/aioupnp/protocols/scpd.py new file mode 100644 index 0000000..d66968b --- /dev/null +++ b/aioupnp/protocols/scpd.py @@ -0,0 +1,88 @@ +import logging +from xml.etree import ElementTree +import asyncio +from asyncio.protocols import Protocol +from aioupnp.fault import UPnPError +from aioupnp.serialization.scpd import deserialize_scpd_get_response +from aioupnp.serialization.scpd import serialize_scpd_get +from aioupnp.serialization.soap import serialize_soap_post, deserialize_soap_post_response + + +log = logging.getLogger(__name__) + + +class SCPDHTTPClientProtocol(Protocol): + POST = 'POST' + GET = 'GET' + + def __init__(self, method: str, message: bytes, finished: asyncio.Future, soap_method: str=None, + soap_service_id: str=None, close_after_send: bool = False): + self.method = method + assert soap_service_id is not None and soap_method is not None if method == 'POST' else True, \ + 'soap args not provided' + self.message = message + self.response_buff = b"" + self.finished = finished + self.soap_method = soap_method + self.soap_service_id = soap_service_id + self.close_after_send = close_after_send + + def connection_made(self, transport): + transport.write(self.message) + if self.close_after_send: + self.finished.set_result(None) + + def data_received(self, data): + self.response_buff += data + if self.method == self.GET: + try: + packet = deserialize_scpd_get_response(self.response_buff) + if not packet: + return + except ElementTree.ParseError: + pass + except UPnPError as err: + self.finished.set_exception(err) + else: + self.finished.set_result(packet) + elif self.method == self.POST: + try: + packet = deserialize_soap_post_response(self.response_buff, self.soap_method, self.soap_service_id) + if not packet: + return + except ElementTree.ParseError: + pass + except UPnPError as err: + self.finished.set_exception(err) + else: + self.finished.set_result(packet) + + +async def scpd_get(control_url: str, address: str, port: int) -> dict: + loop = asyncio.get_running_loop() + finished = asyncio.Future() + packet = serialize_scpd_get(control_url, address) + transport, protocol = await loop.create_connection( + lambda : SCPDHTTPClientProtocol('GET', packet, finished), address, port + ) + try: + return await asyncio.wait_for(finished, 1.0) + finally: + transport.close() + + +async def scpd_post(control_url: str, address: str, port: int, method: str, param_names: list, service_id: bytes, + close_after_send: bool, **kwargs): + loop = asyncio.get_running_loop() + finished = asyncio.Future() + packet = serialize_soap_post(method, param_names, service_id, address.encode(), control_url.encode(), **kwargs) + transport, protocol = await loop.create_connection( + lambda : SCPDHTTPClientProtocol( + 'POST', packet, finished, soap_method=method, soap_service_id=service_id.decode(), + close_after_send=close_after_send + ), address, port + ) + try: + return await asyncio.wait_for(finished, 1.0) + finally: + transport.close() diff --git a/aioupnp/protocols/soap.py b/aioupnp/protocols/soap.py new file mode 100644 index 0000000..8ecaba5 --- /dev/null +++ b/aioupnp/protocols/soap.py @@ -0,0 +1,32 @@ +import logging +from aioupnp.protocols.scpd import scpd_post + +log = logging.getLogger(__name__) + + +class SCPDCommand: + def __init__(self, gateway_address: str, service_port: int, control_url: str, service_id: bytes, method: str, + param_types: dict, return_types: dict, param_order: list, return_order: list): + self.gateway_address = gateway_address + self.service_port = service_port + self.control_url = control_url + self.service_id = service_id + self.method = method + self.param_types = param_types + self.param_order = param_order + self.return_types = return_types + self.return_order = return_order + + async def __call__(self, **kwargs): + if set(kwargs.keys()) != set(self.param_types.keys()): + raise Exception("argument mismatch: %s vs %s" % (kwargs.keys(), self.param_types.keys())) + close_after_send = not self.return_types or self.return_types == [None] + response = await scpd_post( + self.control_url, self.gateway_address, self.service_port, self.method, self.param_order, self.service_id, + close_after_send, **{n: self.param_types[n](kwargs[n]) for n in self.param_types.keys()} + ) + extracted_response = tuple([None if self.return_types[n] is None else self.return_types[n](response[n]) + for n in self.return_order]) or (None, ) + if len(extracted_response) == 1: + return extracted_response[0] + return extracted_response diff --git a/aioupnp/protocols/ssdp.py b/aioupnp/protocols/ssdp.py new file mode 100644 index 0000000..4b2229a --- /dev/null +++ b/aioupnp/protocols/ssdp.py @@ -0,0 +1,104 @@ +import re +import binascii +import asyncio +import logging +from typing import DefaultDict +from asyncio.coroutines import coroutine +from asyncio.futures import Future +from asyncio.transports import DatagramTransport +from aioupnp.fault import UPnPError +from aioupnp.serialization.ssdp import SSDPDatagram +from aioupnp.constants import UPNP_ORG_IGD, SSDP_IP_ADDRESS, SSDP_PORT, SSDP_DISCOVER, SSDP_ROOT_DEVICE +from aioupnp.protocols.multicast import MulticastProtocol + +ADDRESS_REGEX = re.compile("^http:\/\/(\d+\.\d+\.\d+\.\d+)\:(\d*)(\/[\w|\/|\:|\-|\.]*)$") + +log = logging.getLogger(__name__) + + +class SSDPProtocol(MulticastProtocol): + def __init__(self, lan_address): + super().__init__() + self.lan_address = lan_address + self.discover_callbacks: DefaultDict[coroutine] = {} + self.transport: DatagramTransport + self.notifications = [] + self.replies = [] + + def connection_made(self, transport: DatagramTransport): + super().connection_made(transport) + self.set_ttl(1) + + async def m_search(self, address, timeout: int = 1, service=UPNP_ORG_IGD) -> SSDPDatagram: + if (address, service) in self.discover_callbacks: + return self.discover_callbacks[(address, service)] + packet = SSDPDatagram( + SSDPDatagram._M_SEARCH, host="{}:{}".format(SSDP_IP_ADDRESS, SSDP_PORT), st=service, man=SSDP_DISCOVER, + mx=1 + ) + self.transport.sendto(packet.encode().encode(), (address, SSDP_PORT)) + f = Future() + self.discover_callbacks[(address, service)] = f + return await asyncio.wait_for(f, timeout) + + def datagram_received(self, data, addr) -> None: + if addr[0] == self.lan_address: + return + try: + packet = SSDPDatagram.decode(data) + log.debug("decoded %s from %s:%i:\n%s", packet.get_friendly_name(), addr[0], addr[1], packet.encode()) + except UPnPError as err: + log.error("failed to decode SSDP packet from %s:%i: %s\npacket: %s", addr[0], addr[1], err, + binascii.hexlify(data)) + return + + if packet._packet_type == packet._OK: + log.debug("%s:%i sent us an OK", addr[0], addr[1]) + if (addr[0], packet.st) in self.discover_callbacks: + if packet.st not in map(lambda p: p['st'], self.replies): + self.replies.append(packet) + f: Future = self.discover_callbacks.pop((addr[0], packet.st)) + f.set_result(packet) + + elif packet._packet_type == packet._NOTIFY: + if packet.nt == SSDP_ROOT_DEVICE: + address, port, path = ADDRESS_REGEX.findall(packet.location)[0] + key = None + for (addr, service) in self.discover_callbacks: + if addr == address: + key = (addr, service) + break + if key: + log.debug("got a notification with the requested m-search info") + f: Future = self.discover_callbacks.pop(key) + f.set_result(SSDPDatagram( + SSDPDatagram._OK, cache_control='', location=packet.location, server=packet.server, + st=UPNP_ORG_IGD, usn=packet.usn + )) + self.notifications.append(packet.as_dict()) + + +async def listen_ssdp(lan_address: str, gateway_address: str) -> (DatagramTransport, SSDPProtocol, str, str): + loop = asyncio.get_running_loop() + try: + sock = SSDPProtocol.create_socket(lan_address, SSDP_IP_ADDRESS) + transport, protocol = await loop.create_datagram_endpoint( + lambda: SSDPProtocol(lan_address), sock=sock + ) + except Exception: + log.exception("failed to create multicast socket %s:%i", lan_address, SSDP_PORT) + raise + return transport, protocol, gateway_address, lan_address + + +async def m_search(lan_address: str, gateway_address: str, timeout: int = 1, + service: str = UPNP_ORG_IGD) -> SSDPDatagram: + transport, protocol, gateway_address, lan_address = await listen_ssdp( + lan_address, gateway_address + ) + try: + return await protocol.m_search(gateway_address, timeout=timeout, service=service) + except asyncio.TimeoutError: + raise UPnPError("M-SEARCH for {}:{} timed out".format(gateway_address, SSDP_PORT)) + finally: + transport.close() diff --git a/aioupnp/serialization/__init__.py b/aioupnp/serialization/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aioupnp/serialization/scpd.py b/aioupnp/serialization/scpd.py new file mode 100644 index 0000000..d189d52 --- /dev/null +++ b/aioupnp/serialization/scpd.py @@ -0,0 +1,49 @@ +import re +from xml.etree import ElementTree +from aioupnp.constants import XML_VERSION, DEVICE, ROOT +from aioupnp.util import etree_to_dict, flatten_keys + + +CONTENT_PATTERN = re.compile( + "(\<\?xml version=\"1\.0\"\?\>(\s*.)*|\>)".encode() +) + +XML_ROOT_SANITY_PATTERN = re.compile( + "(?i)(\{|(urn:schemas-[\w|\d]*-(com|org|net))[:|-](device|service)[:|-]([\w|\d|\:|\-|\_]*)|\}([\w|\d|\:|\-|\_]*))" +) + + +def serialize_scpd_get(path: str, address: str) -> bytes: + if "http://" in address: + host = address.split("http://")[1] + else: + host = address + if ":" in host: + host = host.split(":")[0] + if not path.startswith("/"): + path = "/" + path + return ( + ( + 'GET %s HTTP/1.1\r\n' + 'Accept-Encoding: gzip\r\n' + 'Host: %s\r\n' + 'Connection: Close\r\n' + '\r\n' + ) % (path, host) + ).encode() + + +def deserialize_scpd_get_response(content: bytes) -> dict: + if XML_VERSION.encode() in content: + parsed = CONTENT_PATTERN.findall(content) + content = b'' if not parsed else parsed[0][0] + xml_dict = etree_to_dict(ElementTree.fromstring(content.decode())) + schema_key = DEVICE + root = ROOT + for k in xml_dict.keys(): + m = XML_ROOT_SANITY_PATTERN.findall(k) + if len(m) == 3 and m[1][0] and m[2][5]: + schema_key = m[1][0] + root = m[2][5] + break + return flatten_keys(xml_dict, "{%s}" % schema_key)[root] diff --git a/aioupnp/serialization/soap.py b/aioupnp/serialization/soap.py new file mode 100644 index 0000000..55ecfed --- /dev/null +++ b/aioupnp/serialization/soap.py @@ -0,0 +1,63 @@ +import re +from xml.etree import ElementTree +from aioupnp.util import etree_to_dict, flatten_keys +from aioupnp.fault import handle_fault, UPnPError +from aioupnp.constants import XML_VERSION, ENVELOPE, BODY + +CONTENT_NO_XML_VERSION_PATTERN = re.compile( + "(\)".encode() +) + + +def serialize_soap_post(method: str, param_names: list, service_id: bytes, gateway_address: bytes, + control_url: bytes, **kwargs) -> bytes: + args = "".join("<%s>%s" % (n, kwargs.get(n), n) for n in param_names) + soap_body = ('\r\n%s\r\n' + '%s' % ( + XML_VERSION, method, service_id.decode(), + args, method)) + if "http://" in gateway_address.decode(): + host = gateway_address.decode().split("http://")[1] + else: + host = gateway_address.decode() + return ( + ( + 'POST %s HTTP/1.1\r\n' + 'Host: %s\r\n' + 'User-Agent: python3/aioupnp, UPnP/1.0, MiniUPnPc/1.9\r\n' + 'Content-Length: %i\r\n' + 'Content-Type: text/xml\r\n' + 'SOAPAction: \"%s#%s\"\r\n' + 'Connection: Close\r\n' + 'Cache-Control: no-cache\r\n' + 'Pragma: no-cache\r\n' + '%s' + '\r\n' + ) % ( + control_url.decode(), # could be just / even if it shouldn't be + host, + len(soap_body), + service_id.decode(), # maybe no quotes + method, + soap_body + ) + ).encode() + + +def deserialize_soap_post_response(response: bytes, method: str, service_id: str) -> dict: + parsed = CONTENT_NO_XML_VERSION_PATTERN.findall(response) + content = b'' if not parsed else parsed[0][0] + content_dict = etree_to_dict(ElementTree.fromstring(content.decode())) + envelope = content_dict[ENVELOPE] + response_body = flatten_keys(envelope[BODY], "{%s}" % service_id) + body = handle_fault(response_body) # raises UPnPError if there is a fault + response_key = None + + for key in body: + if method in key: + response_key = key + break + if not response_key: + raise UPnPError("unknown response fields for %s") + return body[response_key] \ No newline at end of file diff --git a/txupnp/ssdp_datagram.py b/aioupnp/serialization/ssdp.py similarity index 91% rename from txupnp/ssdp_datagram.py rename to aioupnp/serialization/ssdp.py index 2c7c519..00a564e 100644 --- a/txupnp/ssdp_datagram.py +++ b/aioupnp/serialization/ssdp.py @@ -1,12 +1,12 @@ import re import logging import binascii -from txupnp.fault import UPnPError -from txupnp.constants import line_separator +from aioupnp.fault import UPnPError +from aioupnp.constants import line_separator log = logging.getLogger(__name__) -_ssdp_datagram_patterns = { +ssdp_datagram_patterns = { 'host': (re.compile("^(?i)(host):(.*)$"), str), 'st': (re.compile("^(?i)(st):(.*)$"), str), 'man': (re.compile("^(?i)(man):|(\"(.*)\")$"), str), @@ -19,7 +19,7 @@ _ssdp_datagram_patterns = { 'server': (re.compile("^(?i)(server):(.*)$"), str), } -_vendor_pattern = re.compile("^([\w|\d]*)\.([\w|\d]*\.com):([ \"|\w|\d\:]*)$") +vendor_pattern = re.compile("^([\w|\d]*)\.([\w|\d]*\.com):([ \"|\w|\d\:]*)$") class SSDPDatagram(object): @@ -39,9 +39,9 @@ class SSDPDatagram(object): _OK: "m-search response" } - _vendor_field_pattern = _vendor_pattern + _vendor_field_pattern = vendor_pattern - _patterns = _ssdp_datagram_patterns + _patterns = ssdp_datagram_patterns _required_fields = { _M_SEARCH: [ @@ -95,6 +95,9 @@ class SSDPDatagram(object): if not k.startswith("_") and hasattr(self, k.lower()) and getattr(self, k.lower()) is None: setattr(self, k.lower(), v) + def __repr__(self): + return ("SSDPDatagram(packet_type=%s, " % self._packet_type) + ", ".join("%s=%s" % (n, v) for n, v in self.as_dict().items()) + ")" + def __getitem__(self, item): for i in self._required_fields[self._packet_type]: if i.lower() == item.lower(): @@ -166,7 +169,7 @@ class SSDPDatagram(object): lines = [l for l in datagram.split(line_separator) if l] if lines[0] == cls._start_lines[cls._M_SEARCH]: return cls._from_request(lines[1:]) - if lines[0] == cls._start_lines[cls._NOTIFY]: + if lines[0] in [cls._start_lines[cls._NOTIFY], cls._start_lines[cls._NOTIFY] + " "]: return cls._from_notify(lines[1:]) if lines[0] == cls._start_lines[cls._OK]: return cls._from_response(lines[1:]) diff --git a/aioupnp/upnp.py b/aioupnp/upnp.py new file mode 100644 index 0000000..803abf5 --- /dev/null +++ b/aioupnp/upnp.py @@ -0,0 +1,258 @@ +import os +import logging +import json +import asyncio +import functools +from aioupnp.fault import UPnPError +from aioupnp.gateway import Gateway +from aioupnp.constants import UPNP_ORG_IGD +from aioupnp.util import get_gateway_and_lan_addresses +from aioupnp.protocols.ssdp import m_search + +log = logging.getLogger(__name__) + + +def cli(format_result=None): + def _cli(fn): + @functools.wraps(fn) + async def _inner(*args, **kwargs): + result = await fn(*args, **kwargs) + if not format_result or not result or not isinstance(result, (list, dict, tuple)): + return result + self = args[0] + return {k: v for k, v in zip(getattr(self.gateway.commands, format_result).return_order, result)} + f = _inner + f._cli = True + return f + return _cli + + +def _encode(x): + if isinstance(x, bytes): + return x.decode() + elif isinstance(x, Exception): + return str(x) + return x + + +class UPnP: + def __init__(self, lan_address: str, gateway_address: str, gateway: Gateway): + self.lan_address = lan_address + self.gateway_address = gateway_address + self.gateway = gateway + + @classmethod + def get_lan_and_gateway(cls, lan_address: str = '', gateway_address: str = '', + interface_name: str = 'default') -> (str, str): + if not lan_address or not gateway_address: + gateway_addr, lan_addr = get_gateway_and_lan_addresses(interface_name) + lan_address = lan_address or lan_addr + gateway_address = gateway_address or gateway_addr + return lan_address, gateway_address + + @classmethod + async def discover(cls, lan_address: str = '', gateway_address: str = '', timeout: int = 1, + service: str = UPNP_ORG_IGD, interface_name: str = 'default'): + try: + lan_address, gateway_address = cls.get_lan_and_gateway(lan_address, gateway_address, interface_name) + except Exception as err: + raise UPnPError("failed to get lan and gateway addresses: %s" % str(err)) + gateway = await Gateway.discover_gateway(lan_address, gateway_address, timeout, service) + return cls(lan_address, gateway_address, gateway) + + @classmethod + @cli() + async def m_search(cls, lan_address: str = '', gateway_address: str = '', timeout: int = 1, + service: str = UPNP_ORG_IGD, interface_name: str = 'default') -> dict: + lan_address, gateway_address = cls.get_lan_and_gateway(lan_address, gateway_address, interface_name) + datagram = await m_search(lan_address, gateway_address, timeout, service) + return { + 'lan_address': lan_address, + 'gateway_address': gateway_address, + 'discover_reply': datagram.as_dict() + } + + @cli() + async def get_external_ip(self) -> str: + return await self.gateway.commands.GetExternalIPAddress() + + @cli("AddPortMapping") + async def add_port_mapping(self, external_port: int, protocol: str, internal_port, lan_address: str, + description: str) -> None: + return await self.gateway.commands.AddPortMapping( + NewRemoteHost="", NewExternalPort=external_port, NewProtocol=protocol, + NewInternalPort=internal_port, NewInternalClient=lan_address, + NewEnabled=1, NewPortMappingDescription=description, NewLeaseDuration="" + ) + + @cli("GetGenericPortMappingEntry") + async def get_port_mapping_by_index(self, index: int) -> dict: + return await self._get_port_mapping_by_index(index) + + async def _get_port_mapping_by_index(self, index: int) -> (str, int, str, int, str, bool, str, int): + try: + redirect = await self.gateway.commands.GetGenericPortMappingEntry(NewPortMappingIndex=index) + return redirect + except UPnPError: + return + + @cli() + async def get_redirects(self) -> list: + redirects = [] + cnt = 0 + redirect = await self.get_port_mapping_by_index(cnt) + while redirect: + redirects.append(redirect) + cnt += 1 + redirect = await self.get_port_mapping_by_index(cnt) + return redirects + + @cli("GetSpecificPortMappingEntry") + async def get_specific_port_mapping(self, external_port: int, protocol: str): + """ + :param external_port: (int) external port to listen on + :param protocol: (str) 'UDP' | 'TCP' + :return: (int) , (str) , (bool) , (str) , (int) + """ + + try: + return await self.gateway.commands.GetSpecificPortMappingEntry( + NewRemoteHost=None, NewExternalPort=external_port, NewProtocol=protocol + ) + except UPnPError: + return + + @cli() + async def delete_port_mapping(self, external_port: int, protocol: str) -> None: + """ + :param external_port: (int) external port to listen on + :param protocol: (str) 'UDP' | 'TCP' + :return: None + """ + return await self.gateway.commands.DeletePortMapping( + NewRemoteHost="", NewExternalPort=external_port, NewProtocol=protocol + ) + + @cli("AddPortMapping") + async def get_next_mapping(self, port: int, protocol: str, description: str, internal_port: int=None) -> int: + if protocol not in ["UDP", "TCP"]: + raise UPnPError("unsupported protocol: {}".format(protocol)) + internal_port = internal_port or port + redirect_tups = [] + cnt = 0 + port = int(port) + internal_port = int(internal_port) + redirect = await self._get_port_mapping_by_index(cnt) + while redirect: + redirect_tups.append(redirect) + cnt += 1 + redirect = await self._get_port_mapping_by_index(cnt) + + redirects = { + "%i:%s" % (ext_port, proto): (int_host, int_port, desc) + for (ext_host, ext_port, proto, int_port, int_host, enabled, desc, lease) in redirect_tups + } + while ("%i:%s" % (port, protocol)) in redirects: + int_host, int_port, _ = redirects["%i:%s" % (port, protocol)] + if int_host == self.lan_address and int_port == internal_port: + break + port += 1 + + await self.add_port_mapping( # set one up + port, protocol, internal_port, self.lan_address, description + ) + return port + + @cli() + async def get_soap_commands(self) -> dict: + return { + 'supported': list(self.gateway._registered_commands.keys()), + 'unsupported': self.gateway._unsupported_actions + } + + @cli() + async def generate_test_data(self): + external_ip = await self.get_external_ip() + redirects = await self.get_redirects() + ext_port = await self.get_next_mapping(4567, "UDP", "aioupnp test mapping") + delete = await self.delete_port_mapping(ext_port, "UDP") + after_delete = await self.get_specific_port_mapping(ext_port, "UDP") + + commands_test_case = ( + ("get_external_ip", (), "1.2.3.4"), + ("get_redirects", (), redirects), + ("get_next_mapping", (4567, "UDP", "aioupnp test mapping"), ext_port), + ("delete_port_mapping", (ext_port, "UDP"), delete), + ("get_specific_port_mapping", (ext_port, "UDP"), after_delete), + ) + + gateway = self.gateway + device = list(gateway.devices.values())[0] + assert device.manufacturer and device.modelName + device_path = os.path.join(os.getcwd(), "%s %s" % (device.manufacturer, device.modelName)) + commands = gateway.debug_commands() + with open(device_path, "w") as f: + f.write(json.dumps({ + "router_address": self.gateway_address, + "client_address": self.lan_address, + "port": gateway.port, + "gateway_dict": gateway.gateway_descriptor(), + 'expected_devices': [ + { + 'cache_control': 'max-age=1800', + 'location': gateway.location, + 'server': gateway.server, + 'st': gateway.urn, + 'usn': gateway.usn + } + ], + 'commands': commands, + # 'ssdp': u.sspd_factory.get_ssdp_packet_replay(), + # 'scpd': gateway.requester.dump_packets(), + 'soap': commands_test_case + }, default=_encode, indent=2).replace(external_ip, "1.2.3.4")) + return "Generated test data! -> %s" % device_path + + @classmethod + def run_cli(cls, method, lan_address: str = '', gateway_address: str = '', timeout: int = 60, + service: str = UPNP_ORG_IGD, interface_name: str = 'default', + kwargs: dict = None): + kwargs = kwargs or {} + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + fut = asyncio.Future() + + async def wrapper(): + if method == 'm_search': + fn = lambda *_a, **_kw: cls.m_search(lan_address, gateway_address, timeout, service, interface_name) + else: + u = await cls.discover( + lan_address, gateway_address, timeout, service, interface_name + ) + if hasattr(u, method) and hasattr(getattr(u, method), "_cli"): + fn = getattr(u, method) + else: + fut.set_exception(UPnPError("\"%s\" is not a recognized command" % method)) + return + try: + result = await fn(**kwargs) + fut.set_result(result) + except UPnPError as err: + fut.set_exception(err) + except Exception as err: + log.exception("uncaught error") + fut.set_exception(UPnPError("uncaught error: %s" % str(err))) + + asyncio.run(wrapper()) + try: + result = fut.result() + except UPnPError as err: + print("error: %s" % str(err)) + return + + if isinstance(result, (list, tuple, dict)): + print(json.dumps(result, indent=2, default=_encode)) + else: + print(result) diff --git a/txupnp/util.py b/aioupnp/util.py similarity index 52% rename from txupnp/util.py rename to aioupnp/util.py index 7a9557d..0ef4cec 100644 --- a/txupnp/util.py +++ b/aioupnp/util.py @@ -1,7 +1,9 @@ import re -import functools +import socket from collections import defaultdict from xml.etree import ElementTree +import netifaces + BASE_ADDRESS_REGEX = re.compile("^(http:\/\/\d*\.\d*\.\d*\.\d*:\d*)\/.*$".encode()) BASE_PORT_REGEX = re.compile("^http:\/\/\d*\.\d*\.\d*\.\d*:(\d*)\/.*$".encode()) @@ -50,35 +52,37 @@ def get_dict_val_case_insensitive(d, k): raise KeyError("overlapping keys") return d[match[0]] - -def verify_return_types(*types): - """ - Attempt to recast results to expected result types - """ - - def _verify_return_types(fn): - @functools.wraps(fn) - def _inner(*result): - r = fn(*tuple(t(r) for t, r in zip(types, result))) - if isinstance(r, tuple) and len(r) == 1: - return r[0] - return r - return _inner - return _verify_return_types +# import struct +# import fcntl +# def get_ip_address(ifname): +# SIOCGIFADDR = 0x8915 +# s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +# return socket.inet_ntoa(fcntl.ioctl( +# s.fileno(), +# SIOCGIFADDR, +# struct.pack(b'256s', ifname[:15].encode()) +# )[20:24]) -def return_types(*types): - """ - Decorator to set the expected return types of a SOAP function call - """ - - def return_types_wrapper(fn): - fn._return_types = types - return fn - - return return_types_wrapper +def get_interfaces(): + r = { + interface_name: (router_address, netifaces.ifaddresses(interface_name)[netifaces.AF_INET][0]['addr']) + for router_address, interface_name, _ in netifaces.gateways()[socket.AF_INET] + } + for interface_name in netifaces.interfaces(): + if interface_name in ['lo', 'localhost'] or interface_name in r: + continue + addresses = netifaces.ifaddresses(interface_name) + if netifaces.AF_INET in addresses: + address = addresses[netifaces.AF_INET][0]['addr'] + gateway_guess = ".".join(address.split(".")[:-1] + ["1"]) + r[interface_name] = (gateway_guess, address) + r['default'] = r[netifaces.gateways()['default'][netifaces.AF_INET][1]] + return r -none_or_str = lambda x: None if not x or x == 'None' else str(x) - -none = lambda _: None +def get_gateway_and_lan_addresses(interface_name: str) -> (str, str): + for iface_name, (gateway, lan) in get_interfaces().items(): + if interface_name == iface_name: + return gateway, lan + return None, None diff --git a/setup.py b/setup.py index 8e06814..3c4ab90 100644 --- a/setup.py +++ b/setup.py @@ -1,12 +1,12 @@ import os from setuptools import setup, find_packages -from txupnp import __version__, __name__, __email__, __author__, __license__ +from aioupnp import __version__, __name__, __email__, __author__, __license__ console_scripts = [ - 'txupnp-cli = txupnp.cli:main', + 'aioupnp = aioupnp.__main__:main', ] -package_name = "txupnp" +package_name = "aioupnp" base_dir = os.path.abspath(os.path.dirname(__file__)) with open(os.path.join(base_dir, 'README.md'), 'rb') as f: long_description = f.read().decode('utf-8') @@ -16,15 +16,14 @@ setup( version=__version__, author=__author__, author_email=__email__, - description="UPnP for twisted", - keywords="upnp twisted", + description="UPnP for asyncio", + keywords="upnp asyncio", long_description=long_description, - url="https://github.com/lbryio/txupnp", + url="https://github.com/lbryio/aioupnp", license=__license__, packages=find_packages(exclude=['tests']), entry_points={'console_scripts': console_scripts}, install_requires=[ - 'twisted[tls]', 'netifaces', ], ) diff --git a/tests/__init__.py b/tests/__init__.py index 7082255..bd461ed 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,6 +1,6 @@ import logging -log = logging.getLogger("txupnp") +log = logging.getLogger("aioupnp") handler = logging.StreamHandler() handler.setFormatter(logging.Formatter('%(asctime)-15s-%(filename)s:%(lineno)s->%(message)s')) log.addHandler(handler) diff --git a/tests/devices/D-Link DIR-890L b/tests/devices/D-Link DIR-890L new file mode 100644 index 0000000..a255822 --- /dev/null +++ b/tests/devices/D-Link DIR-890L @@ -0,0 +1,260 @@ +{ + "router_address": "10.0.0.1", + "client_address": "10.0.0.136", + "port": 49152, + "gateway_dict": { + "server": "Linux, UPnP/1.0, DIR-890L Ver 1.20", + "urlBase": "http://10.0.0.1:49152", + "location": "http://10.0.0.1:49152/InternetGatewayDevice.xml", + "specVersion": { + "major": "1", + "minor": "0" + }, + "usn": "uuid:81195F69-620A-380A-A9FA-908D784C0DDF::urn:schemas-upnp-org:device:InternetGatewayDevice:1", + "urn": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" + }, + "expected_devices": [ + { + "cache_control": "max-age=1800", + "location": "http://10.0.0.1:49152/InternetGatewayDevice.xml", + "server": "Linux, UPnP/1.0, DIR-890L Ver 1.20", + "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1", + "usn": "uuid:81195F69-620A-380A-A9FA-908D784C0DDF::urn:schemas-upnp-org:device:InternetGatewayDevice:1" + } + ], + "commands": { + "available": { + "GetDefaultConnectionService": "urn:schemas-upnp-org:service:Layer3Forwarding:1", + "SetDefaultConnectionService": "urn:schemas-upnp-org:service:Layer3Forwarding:1", + "GetCommonLinkProperties": "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", + "GetTotalBytesSent": "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", + "GetTotalBytesReceived": "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", + "GetTotalPacketsSent": "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", + "GetTotalPacketsReceived": "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", + "X_GetICSStatistics": "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", + "SetConnectionType": "urn:schemas-upnp-org:service:WANIPConnection:1", + "GetConnectionTypeInfo": "urn:schemas-upnp-org:service:WANIPConnection:1", + "RequestConnection": "urn:schemas-upnp-org:service:WANIPConnection:1", + "ForceTermination": "urn:schemas-upnp-org:service:WANIPConnection:1", + "GetStatusInfo": "urn:schemas-upnp-org:service:WANIPConnection:1", + "GetNATRSIPStatus": "urn:schemas-upnp-org:service:WANIPConnection:1", + "GetGenericPortMappingEntry": "urn:schemas-upnp-org:service:WANIPConnection:1", + "GetSpecificPortMappingEntry": "urn:schemas-upnp-org:service:WANIPConnection:1", + "AddPortMapping": "urn:schemas-upnp-org:service:WANIPConnection:1", + "DeletePortMapping": "urn:schemas-upnp-org:service:WANIPConnection:1", + "GetExternalIPAddress": "urn:schemas-upnp-org:service:WANIPConnection:1" + }, + "failed": { + "urn:schemas-upnp-org:service:WANEthernetLinkConfig:1": [ + "GetEthernetLinkStatus" + ] + } + }, + "ssdp": { + "lan_address": "10.0.0.136", + "router_address": "10.0.0.1", + "sent": [ + "M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\nST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\nMAN: \"ssdp:discover\"\r\nMX: 1\r\n\r\n" + ], + "received": [ + [ + [ + "10.0.0.1", + 33602 + ], + "HTTP/1.1 200 OK\r\nCACHE-CONTROL: max-age=1800\r\nDATE: Fri, 05 Oct 2018 15:48:28 GMT\r\nEXT:\r\nLOCATION: http://10.0.0.1:49152/InternetGatewayDevice.xml\r\nSERVER: Linux, UPnP/1.0, DIR-890L Ver 1.20\r\nST: uuid:81195F69-620A-380A-A9FA-908D784C0DDF\r\nUSN: uuid:81195F69-620A-380A-A9FA-908D784C0DDF\r\n\r\n" + ], + [ + [ + "10.0.0.1", + 47062 + ], + "HTTP/1.1 200 OK\r\nCACHE-CONTROL: max-age=1800\r\nDATE: Fri, 05 Oct 2018 15:48:28 GMT\r\nEXT:\r\nLOCATION: http://10.0.0.1:49152/InternetGatewayDevice.xml\r\nSERVER: Linux, UPnP/1.0, DIR-890L Ver 1.20\r\nST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\nUSN: uuid:81195F69-620A-380A-A9FA-908D784C0DDF::urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n\r\n" + ] + ] + }, + "scpd": { + "GET": { + "InternetGatewayDevice.xml": { + "request": "GET /InternetGatewayDevice.xml HTTP/1.1\r\nAccept-Encoding: gzip\r\nHost: 10.0.0.1\r\nConnection: Close\r\n\r\n", + "response": "\n\n\t\n\t\t1\n\t\t0\n\t\n\thttp://10.0.0.1:49152\n\t\n\t\turn:schemas-upnp-org:device:InternetGatewayDevice:1\n\t\tWireless Broadband Router\n\t\tD-Link Corporation\n\t\thttp://www.dlink.com\n\t\tD-Link Router\n\t\tD-Link Router\n\t\tDIR-890L\n\t\thttp://www.dlink.com\n\t\t120\n\t\tuuid:81195F69-620A-380A-A9FA-908D784C0DDF\n\t\t\n\t\t\t\n\t\t\t\timage/gif\n\t\t\t\t118\n\t\t\t\t119\n\t\t\t\t8\n\t\t\t\t/ligd.gif\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\turn:schemas-microsoft-com:service:OSInfo:1\n\t\t\t\turn:microsoft-com:serviceId:OSInfo1\n\t\t\t\t/soap.cgi?service=OSInfo1\n\t\t\t\t/gena.cgi?service=OSInfo1\n\t\t\t\t/OSInfo.xml\n\t\t\t\n\t\t\t\n\t\t\t\turn:schemas-upnp-org:service:Layer3Forwarding:1\n\t\t\t\turn:upnp-org:serviceId:L3Forwarding1\n\t\t\t\t/soap.cgi?service=L3Forwarding1\n\t\t\t\t/gena.cgi?service=L3Forwarding1\n\t\t\t\t/Layer3Forwarding.xml\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\turn:schemas-upnp-org:device:WANDevice:1\n\t\t\t\tWANDevice\n\t\t\t\tD-Link\n\t\t\t\thttp://www.dlink.com\n\t\t\t\tWANDevice\n\t\t\t\tDIR-890L\n\t\t\t\t1\n\t\t\t\thttp://www.dlink.com\n\t\t\t\t120\n\t\t\t\tuuid:81195F69-620A-380A-A9FA-908D784C0DDF\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\turn:schemas-upnp-org:service:WANCommonInterfaceConfig:1\n\t\t\t\t\t\turn:upnp-org:serviceId:WANCommonIFC1\n\t\t\t\t\t\t/soap.cgi?service=WANCommonIFC1\n\t\t\t\t\t\t/gena.cgi?service=WANCommonIFC1\n\t\t\t\t\t\t/WANCommonInterfaceConfig.xml\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\turn:schemas-upnp-org:device:WANConnectionDevice:1\n\t\t\t\t\t\tWANConnectionDevice\n\t\t\t\t\t\tD-Link\n\t\t\t\t\t\thttp://www.dlink.com\n\t\t\t\t\t\tWanConnectionDevice\n\t\t\t\t\t\tDIR-890L\n\t\t\t\t\t\t1\n\t\t\t\t\t\thttp://www.dlink.com\n\t\t\t\t\t\t120\n\t\t\t\t\t\tuuid:81195F69-620A-380A-A9FA-908D784C0DDF\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\turn:schemas-upnp-org:service:WANEthernetLinkConfig:1\n\t\t\t\t\t\t\t\turn:upnp-org:serviceId:WANEthLinkC1\n\t\t\t\t\t\t\t\t/soap.cgi?service=WANEthLinkC1\n\t\t\t\t\t\t\t\t/gena.cgi?service=WANEthLinkC1\n\t\t\t\t\t\t\t\t/WANEthernetLinkConfig.xml\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\turn:schemas-upnp-org:service:WANIPConnection:1\n\t\t\t\t\t\t\t\turn:upnp-org:serviceId:WANIPConn1\n\t\t\t\t\t\t\t\t/soap.cgi?service=WANIPConn1\n\t\t\t\t\t\t\t\t/gena.cgi?service=WANIPConn1\n\t\t\t\t\t\t\t\t/WANIPConnection.xml\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t\thttp://10.0.0.1\n\t\n" + }, + "OSInfo.xml": { + "request": "GET /OSInfo.xml HTTP/1.1\r\nAccept-Encoding: gzip\r\nHost: 10.0.0.1\r\nConnection: Close\r\n\r\n", + "response": "\n\n\t\n\t\t1\n\t\t0\n\t\n\t\n\t\n\t\n\t\n" + }, + "Layer3Forwarding.xml": { + "request": "GET /Layer3Forwarding.xml HTTP/1.1\r\nAccept-Encoding: gzip\r\nHost: 10.0.0.1\r\nConnection: Close\r\n\r\n", + "response": "\n\n\t\n\t\t1\n\t\t0\n\t\n\t\n\t\t\n\t\t\tGetDefaultConnectionService\n\t\t\t\n\t\t\t\t\n\t\t\t\t\tNewDefaultConnectionService\n\t\t\t\t\tout\n\t\t\t\t\tDefaultConnectionService\n\t\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\tSetDefaultConnectionService\n\t\t\t\n\t\t\t\t\n\t\t\t\t\tNewDefaultConnectionService\n\t\t\t\t\tin\n\t\t\t\t\tDefaultConnectionService\n\t\t\t\t\n\t\t\t\n\t\t\n\t\n\t\n\t\t\n\t\t\tDefaultConnectionService\n\t\t\tstring\n\t\t\n\t\n" + }, + "WANCommonInterfaceConfig.xml": { + "request": "GET /WANCommonInterfaceConfig.xml HTTP/1.1\r\nAccept-Encoding: gzip\r\nHost: 10.0.0.1\r\nConnection: Close\r\n\r\n", + "response": "\r\n\r\n\t\r\n\t\t1\r\n\t\t0\r\n\t\r\n\t\r\n\t\t\r\n\t\t\tGetCommonLinkProperties\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewWANAccessType\r\n\t\t\t\t\tout\r\n\t\t\t\t\tWANAccessType\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewLayer1UpstreamMaxBitRate\r\n\t\t\t\t\tout\r\n\t\t\t\t\tLayer1UpstreamMaxBitRate\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewLayer1DownstreamMaxBitRate\r\n\t\t\t\t\tout\r\n\t\t\t\t\tLayer1DownstreamMaxBitRate\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewPhysicalLinkStatus\r\n\t\t\t\t\tout\r\n\t\t\t\t\tPhysicalLinkStatus\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tGetTotalBytesSent\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewTotalBytesSent\r\n\t\t\t\t\tout\r\n\t\t\t\t\tTotalBytesSent\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tGetTotalBytesReceived\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewTotalBytesReceived\r\n\t\t\t\t\tout\r\n\t\t\t\t\tTotalBytesReceived\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tGetTotalPacketsSent\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewTotalPacketsSent\r\n\t\t\t\t\tout\r\n\t\t\t\t\tTotalPacketsSent\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tGetTotalPacketsReceived\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewTotalPacketsReceived\r\n\t\t\t\t\tout\r\n\t\t\t\t\tTotalPacketsReceived\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tX_GetICSStatistics\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tTotalBytesSent\r\n\t\t\t\t\tout\r\n\t\t\t\t\tTotalBytesSent\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tTotalBytesReceived\r\n\t\t\t\t\tout\r\n\t\t\t\t\tTotalBytesReceived\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tTotalPacketsSent\r\n\t\t\t\t\tout\r\n\t\t\t\t\tTotalPacketsSent\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tTotalPacketsReceived\r\n\t\t\t\t\tout\r\n\t\t\t\t\tTotalPacketsReceived\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tLayer1DownstreamMaxBitRate\r\n\t\t\t\t\tout\r\n\t\t\t\t\tLayer1DownstreamMaxBitRate\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tUptime\r\n\t\t\t\t\tout\r\n\t\t\t\t\tX_Uptime\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\r\n\t\r\n\t\t\r\n\t\t\tWANAccessType\r\n\t\t\tstring\r\n\t\t\t\r\n\t\t\t\tDSL\r\n\t\t\t\tPOTS\r\n\t\t\t\tCable\r\n\t\t\t\tEthernet\r\n\t\t\t\tOther\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tLayer1UpstreamMaxBitRate\r\n\t\t\tui4\r\n\t\t\r\n\t\t\r\n\t\t\tLayer1DownstreamMaxBitRate\r\n\t\t\tui4\r\n\t\t\r\n\t\t\r\n\t\t\tPhysicalLinkStatus\r\n\t\t\tstring\r\n\t\t\t\r\n\t\t\t\tUp\r\n\t\t\t\tDown\r\n\t\t\t\tInitializing\r\n\t\t\t\tUnavailable\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tWANAccessProvider\r\n\t\t\tstring\r\n\t\t\r\n\t\t\r\n\t\t\tMaximumActiveConnections\r\n\t\t\tui2\r\n\t\t\t\r\n\t\t\t\t1\r\n\t\t\t\t\r\n\t\t\t\t1\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tTotalBytesSent\r\n\t\t\tui4\r\n\t\t\r\n\t\t\r\n\t\t\tTotalBytesReceived\r\n\t\t\tui4\r\n\t\t\r\n\t\t\r\n\t\t\tTotalPacketsSent\r\n\t\t\tui4\r\n\t\t\r\n\t\t\r\n\t\t\tTotalPacketsReceived\r\n\t\t\tui4\r\n\t\t\r\n\t\t\r\n\t\t\tX_PersonalFirewallEnabled\r\n\t\t\tboolean\r\n\t\t\r\n\t\t\r\n\t\t\tX_Uptime\r\n\t\t\tui4\r\n\t\t\r\n\t\r\n\r" + }, + "WANEthernetLinkConfig.xml": { + "request": "GET /WANEthernetLinkConfig.xml HTTP/1.1\r\nAccept-Encoding: gzip\r\nHost: 10.0.0.1\r\nConnection: Close\r\n\r\n", + "response": "\n\n\t\n\t\t1\n\t\t0\n\t\n\t\n\t\t\n\t\t\tGetEthernetLinkStatus\n\t\t\t\n\t\t\t\t\n\t\t\t\t\tNewEthernetLinkStatus\n\t\t\t\t\tout\n\t\t\t\t\tEthernetLinkStatus\n\t\t\t\t\n\t\t\t\n\t\t\n\t\n\t\n\t\t\n\t\t\tEthernetLinkStatus\n\t\t\tstring\n\t\t\t\n\t\t\t\tUp\n\t\t\t\tDown\n\t\t\t\tUnavailable\n\t\t\t\n\t\t\n\t\n" + }, + "WANIPConnection.xml": { + "request": "GET /WANIPConnection.xml HTTP/1.1\r\nAccept-Encoding: gzip\r\nHost: 10.0.0.1\r\nConnection: Close\r\n\r\n", + "response": "\r\n\r\n\t\r\n\t\t1\r\n\t\t0\r\n\t\r\n\t\r\n\t\t\r\n\t\t\tSetConnectionType\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewConnectionType\r\n\t\t\t\t\tin\r\n\t\t\t\t\tConnectionType\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t \r\n\t\t\r\n\t\t\tGetConnectionTypeInfo\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewConnectionType\r\n\t\t\t\t\tout\r\n\t\t\t\t\tConnectionType\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewPossibleConnectionTypes\r\n\t\t\t\t\tout\r\n\t\t\t\t\tPossibleConnectionTypes\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tRequestConnection\r\n\t\t\r\n\t\t\r\n\t\t\tForceTermination\r\n\t\t\r\n\t\t\r\n\t\t\tGetStatusInfo\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewConnectionStatus\r\n\t\t\t\t\tout\r\n\t\t\t\t\tConnectionStatus\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewLastConnectionError\r\n\t\t\t\t\tout\r\n\t\t\t\t\tLastConnectionError\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewUptime\r\n\t\t\t\t\tout\r\n\t\t\t\t\tUptime\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tGetNATRSIPStatus\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewRSIPAvailable\r\n\t\t\t\t\tout\r\n\t\t\t\t\tRSIPAvailable\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewNATEnabled\r\n\t\t\t\t\tout\r\n\t\t\t\t\tNATEnabled\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tGetGenericPortMappingEntry\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewPortMappingIndex\r\n\t\t\t\t\tin\r\n\t\t\t\t\tPortMappingNumberOfEntries\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewRemoteHost\r\n\t\t\t\t\tout\r\n\t\t\t\t\tRemoteHost\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewExternalPort\r\n\t\t\t\t\tout\r\n\t\t\t\t\tExternalPort\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewProtocol\r\n\t\t\t\t\tout\r\n\t\t\t\t\tPortMappingProtocol\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewInternalPort\r\n\t\t\t\t\tout\r\n\t\t\t\t\tInternalPort\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewInternalClient\r\n\t\t\t\t\tout\r\n\t\t\t\t\tInternalClient\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewEnabled\r\n\t\t\t\t\tout\r\n\t\t\t\t\tPortMappingEnabled\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewPortMappingDescription\r\n\t\t\t\t\tout\r\n\t\t\t\t\tPortMappingDescription\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewLeaseDuration\r\n\t\t\t\t\tout\r\n\t\t\t\t\tPortMappingLeaseDuration\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tGetSpecificPortMappingEntry\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewRemoteHost\r\n\t\t\t\t\tin\r\n\t\t\t\t\tRemoteHost\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewExternalPort\r\n\t\t\t\t\tin\r\n\t\t\t\t\tExternalPort\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewProtocol\r\n\t\t\t\t\tin\r\n\t\t\t\t\tPortMappingProtocol\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewInternalPort\r\n\t\t\t\t\tout\r\n\t\t\t\t\tInternalPort\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewInternalClient\r\n\t\t\t\t\tout\r\n\t\t\t\t\tInternalClient\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewEnabled\r\n\t\t\t\t\tout\r\n\t\t\t\t\tPortMappingEnabled\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewPortMappingDescription\r\n\t\t\t\t\tout\r\n\t\t\t\t\tPortMappingDescription\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewLeaseDuration\r\n\t\t\t\t\tout\r\n\t\t\t\t\tPortMappingLeaseDuration\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tAddPortMapping\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewRemoteHost\r\n\t\t\t\t\tin\r\n\t\t\t\t\tRemoteHost\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewExternalPort\r\n\t\t\t\t\tin\r\n\t\t\t\t\tExternalPort\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewProtocol\r\n\t\t\t\t\tin\r\n\t\t\t\t\tPortMappingProtocol\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewInternalPort\r\n\t\t\t\t\tin\r\n\t\t\t\t\tInternalPort\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewInternalClient\r\n\t\t\t\t\tin\r\n\t\t\t\t\tInternalClient\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewEnabled\r\n\t\t\t\t\tin\r\n\t\t\t\t\tPortMappingEnabled\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewPortMappingDescription\r\n\t\t\t\t\tin\r\n\t\t\t\t\tPortMappingDescription\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewLeaseDuration\r\n\t\t\t\t\tin\r\n\t\t\t\t\tPortMappingLeaseDuration\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tDeletePortMapping\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewRemoteHost\r\n\t\t\t\t\tin\r\n\t\t\t\t\tRemoteHost\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewExternalPort\r\n\t\t\t\t\tin\r\n\t\t\t\t\tExternalPort\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewProtocol\r\n\t\t\t\t\tin\r\n\t\t\t\t\tPortMappingProtocol\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tGetExternalIPAddress\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewExternalIPAddress\r\n\t\t\t\t\tout\r\n\t\t\t\t\tExternalIPAddress\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\r\n\t\r\n\t\t\r\n\t\t\tConnectionType\r\n\t\t\tstring\r\n\t\t\tUnconfigured\r\n\t\t\r\n\t\t\r\n\t\t\tPossibleConnectionTypes\r\n\t\t\tstring\r\n\t\t\t\r\n\t\t\t\tUnconfigured\r\n\t\t\t\tIP_Routed\r\n\t\t\t\tIP_Bridged\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tConnectionStatus\r\n\t\t\tstring\r\n\t\t\tUnconfigured\r\n\t\t\t\r\n\t\t\t\tUnconfigured\r\n\t\t\t\tConnecting\r\n\t\t\t\tAuthenticating\r\n\t\t\t\tPendingDisconnect\r\n\t\t\t\tDisconnecting\r\n\t\t\t\tDisconnected\r\n\t\t\t\tConnected\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tUptime\r\n\t\t\tui4\r\n\t\t\t0\r\n\t\t\t\r\n\t\t\t\t0\r\n\t\t\t\t\r\n\t\t\t\t1\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tRSIPAvailable\r\n\t\tboolean\r\n\t\t\t0\r\n\t\t\r\n\t\t\r\n\t\t\tNATEnabled\r\n\t\t\tboolean\r\n\t\t\t1\r\n\t\t \r\n\t\t\r\n\t\t\tX_Name\r\n\t\t\tstring\r\n\t\t\r\n\t\t\r\n\t\t\tLastConnectionError\r\n\t\t\tstring\r\n\t\t\tERROR_NONE\r\n\t\t\t\r\n\t\t\t\tERROR_NONE\r\n\t\t\t\tERROR_ISP_TIME_OUT\r\n\t\t\t\tERROR_COMMAND_ABORTED\r\n\t\t\t\tERROR_NOT_ENABLED_FOR_INTERNET\r\n\t\t\t\tERROR_BAD_PHONE_NUMBER\r\n\t\t\t\tERROR_USER_DISCONNECT\r\n\t\t\t\tERROR_ISP_DISCONNECT\r\n\t\t\t\tERROR_IDLE_DISCONNECT\r\n\t\t\t\tERROR_FORCED_DISCONNECT\r\n\t\t\t\tERROR_SERVER_OUT_OF_RESOURCES\r\n\t\t\t\tERROR_RESTRICTED_LOGON_HOURS\r\n\t\t\t\tERROR_ACCOUNT_DISABLED\r\n\t\t\t\tERROR_ACCOUNT_EXPIRED\r\n\t\t\t\tERROR_PASSWORD_EXPIRED\r\n\t\t\t\tERROR_AUTHENTICATION_FAILURE\r\n\t\t\t\tERROR_NO_DIALTONE\r\n\t\t\t\tERROR_NO_CARRIER\r\n\t\t\t\tERROR_NO_ANSWER\r\n\t\t\t\tERROR_LINE_BUSY\r\n\t\t\t\tERROR_UNSUPPORTED_BITSPERSECOND\r\n\t\t\t\tERROR_TOO_MANY_LINE_ERRORS\r\n\t\t\t\tERROR_IP_CONFIGURATION\r\n\t\t\t\tERROR_UNKNOWN\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tExternalIPAddress\r\n\t\t\tstring\r\n\t\t\r\n\t\t\r\n\t\t\tRemoteHost\r\n\t\t\tstring\r\n\t\t\r\n\t\t\r\n\t\t\tExternalPort\r\n\t\t\tui2\r\n\t\t\r\n\t\t\r\n\t\t\tInternalPort\r\n\t\t\tui2\r\n\t\t\r\n\t\t\r\n\t\t\tPortMappingProtocol\r\n\t\t\tstring\r\n\t\t\t\r\n\t\t\t\tTCP\r\n\t\t\t\tUDP\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tInternalClient\r\n\t\t\tstring\r\n\t\t\r\n\t\t\r\n\t\t\tPortMappingDescription\r\n\t\t\tstring\r\n\t\t\r\n\t\t\r\n\t\t\tPortMappingEnabled\r\n\t\t\tboolean\r\n\t\t\r\n\t\t\r\n\t\t\tPortMappingLeaseDuration\r\n\t\t\tui4\r\n\t\t\r\n\t\t\r\n\t\t\tPortMappingNumberOfEntries\r\n\t\t\tui2\r\n\t\t\r\n\t\r\n\r" + } + }, + "POST": { + "http://10.0.0.1:49152/soap.cgi?service=WANIPConn1": [ + { + "request": "POST /soap.cgi?service=WANIPConn1 HTTP/1.1\r\nHost: 10.0.0.1:49152\r\nUser-Agent: python3/txupnp, UPnP/1.0, MiniUPnPc/1.9\r\nContent-Length: 285\r\nContent-Type: text/xml\r\nSOAPAction: \"urn:schemas-upnp-org:service:WANIPConnection:1#GetExternalIPAddress\"\r\nConnection: Close\r\nCache-Control: no-cache\r\nPragma: no-cache\r\n\r\n\r\n\r\n", + "response": "\n\n\t\n\t\t\n1.2.3.4\n\n\t\n" + }, + { + "request": "POST /soap.cgi?service=WANIPConn1 HTTP/1.1\r\nHost: 10.0.0.1:49152\r\nUser-Agent: python3/txupnp, UPnP/1.0, MiniUPnPc/1.9\r\nContent-Length: 341\r\nContent-Type: text/xml\r\nSOAPAction: \"urn:schemas-upnp-org:service:WANIPConnection:1#GetGenericPortMappingEntry\"\r\nConnection: Close\r\nCache-Control: no-cache\r\nPragma: no-cache\r\n\r\n\r\n0\r\n", + "response": "\n\n\t\n\t\t\n\n\n9308\nUDP\n9308\n10.0.0.94\n1\n10.0.0.94:9308 to 9308 (UDP)\n0\n\n\t\n" + }, + { + "request": "POST /soap.cgi?service=WANIPConn1 HTTP/1.1\r\nHost: 10.0.0.1:49152\r\nUser-Agent: python3/txupnp, UPnP/1.0, MiniUPnPc/1.9\r\nContent-Length: 341\r\nContent-Type: text/xml\r\nSOAPAction: \"urn:schemas-upnp-org:service:WANIPConnection:1#GetGenericPortMappingEntry\"\r\nConnection: Close\r\nCache-Control: no-cache\r\nPragma: no-cache\r\n\r\n\r\n1\r\n", + "response": "\n\n\t\n\t\t\n\n\n3333\nTCP\n3333\n10.0.0.11\n1\nLBRY peer port\n0\n\n\t\n" + }, + { + "request": "POST /soap.cgi?service=WANIPConn1 HTTP/1.1\r\nHost: 10.0.0.1:49152\r\nUser-Agent: python3/txupnp, UPnP/1.0, MiniUPnPc/1.9\r\nContent-Length: 341\r\nContent-Type: text/xml\r\nSOAPAction: \"urn:schemas-upnp-org:service:WANIPConnection:1#GetGenericPortMappingEntry\"\r\nConnection: Close\r\nCache-Control: no-cache\r\nPragma: no-cache\r\n\r\n\r\n2\r\n", + "response": "\n\n\t\n\t\t\n\n\n4444\nUDP\n4444\n10.0.0.11\n1\nLBRY DHT port\n0\n\n\t\n" + }, + { + "request": "POST /soap.cgi?service=WANIPConn1 HTTP/1.1\r\nHost: 10.0.0.1:49152\r\nUser-Agent: python3/txupnp, UPnP/1.0, MiniUPnPc/1.9\r\nContent-Length: 341\r\nContent-Type: text/xml\r\nSOAPAction: \"urn:schemas-upnp-org:service:WANIPConnection:1#GetGenericPortMappingEntry\"\r\nConnection: Close\r\nCache-Control: no-cache\r\nPragma: no-cache\r\n\r\n\r\n3\r\n", + "response": "\n\n\t\n\t\t\n\n\n9305\nUDP\n9305\n10.0.0.94\n1\n10.0.0.94:9305 to 9305 (UDP)\n0\n\n\t\n" + }, + { + "request": "POST /soap.cgi?service=WANIPConn1 HTTP/1.1\r\nHost: 10.0.0.1:49152\r\nUser-Agent: python3/txupnp, UPnP/1.0, MiniUPnPc/1.9\r\nContent-Length: 341\r\nContent-Type: text/xml\r\nSOAPAction: \"urn:schemas-upnp-org:service:WANIPConnection:1#GetGenericPortMappingEntry\"\r\nConnection: Close\r\nCache-Control: no-cache\r\nPragma: no-cache\r\n\r\n\r\n4\r\n", + "response": "\n\n\t\n\t\t\n\n\n9306\nUDP\n9306\n10.0.0.94\n1\n10.0.0.94:9306 to 9306 (UDP)\n0\n\n\t\n" + }, + { + "request": "POST /soap.cgi?service=WANIPConn1 HTTP/1.1\r\nHost: 10.0.0.1:49152\r\nUser-Agent: python3/txupnp, UPnP/1.0, MiniUPnPc/1.9\r\nContent-Length: 341\r\nContent-Type: text/xml\r\nSOAPAction: \"urn:schemas-upnp-org:service:WANIPConnection:1#GetGenericPortMappingEntry\"\r\nConnection: Close\r\nCache-Control: no-cache\r\nPragma: no-cache\r\n\r\n\r\n5\r\n", + "response": "\n\n\t\n\t\t\n\t\t\ts:Client\n\t\t\tUPnPError\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t713\n\t\t\t\t\tSpecifiedArrayIndexInvalid\n\t\t\t\t\n\t\t\t\n\t\t\n\t\n" + }, + { + "request": "POST /soap.cgi?service=WANIPConn1 HTTP/1.1\r\nHost: 10.0.0.1:49152\r\nUser-Agent: python3/txupnp, UPnP/1.0, MiniUPnPc/1.9\r\nContent-Length: 341\r\nContent-Type: text/xml\r\nSOAPAction: \"urn:schemas-upnp-org:service:WANIPConnection:1#GetGenericPortMappingEntry\"\r\nConnection: Close\r\nCache-Control: no-cache\r\nPragma: no-cache\r\n\r\n\r\n0\r\n", + "response": "\n\n\t\n\t\t\n\n\n9308\nUDP\n9308\n10.0.0.94\n1\n10.0.0.94:9308 to 9308 (UDP)\n0\n\n\t\n" + }, + { + "request": "POST /soap.cgi?service=WANIPConn1 HTTP/1.1\r\nHost: 10.0.0.1:49152\r\nUser-Agent: python3/txupnp, UPnP/1.0, MiniUPnPc/1.9\r\nContent-Length: 341\r\nContent-Type: text/xml\r\nSOAPAction: \"urn:schemas-upnp-org:service:WANIPConnection:1#GetGenericPortMappingEntry\"\r\nConnection: Close\r\nCache-Control: no-cache\r\nPragma: no-cache\r\n\r\n\r\n1\r\n", + "response": "\n\n\t\n\t\t\n\n\n3333\nTCP\n3333\n10.0.0.11\n1\nLBRY peer port\n0\n\n\t\n" + }, + { + "request": "POST /soap.cgi?service=WANIPConn1 HTTP/1.1\r\nHost: 10.0.0.1:49152\r\nUser-Agent: python3/txupnp, UPnP/1.0, MiniUPnPc/1.9\r\nContent-Length: 341\r\nContent-Type: text/xml\r\nSOAPAction: \"urn:schemas-upnp-org:service:WANIPConnection:1#GetGenericPortMappingEntry\"\r\nConnection: Close\r\nCache-Control: no-cache\r\nPragma: no-cache\r\n\r\n\r\n2\r\n", + "response": "\n\n\t\n\t\t\n\n\n4444\nUDP\n4444\n10.0.0.11\n1\nLBRY DHT port\n0\n\n\t\n" + }, + { + "request": "POST /soap.cgi?service=WANIPConn1 HTTP/1.1\r\nHost: 10.0.0.1:49152\r\nUser-Agent: python3/txupnp, UPnP/1.0, MiniUPnPc/1.9\r\nContent-Length: 341\r\nContent-Type: text/xml\r\nSOAPAction: \"urn:schemas-upnp-org:service:WANIPConnection:1#GetGenericPortMappingEntry\"\r\nConnection: Close\r\nCache-Control: no-cache\r\nPragma: no-cache\r\n\r\n\r\n3\r\n", + "response": "\n\n\t\n\t\t\n\n\n9305\nUDP\n9305\n10.0.0.94\n1\n10.0.0.94:9305 to 9305 (UDP)\n0\n\n\t\n" + }, + { + "request": "POST /soap.cgi?service=WANIPConn1 HTTP/1.1\r\nHost: 10.0.0.1:49152\r\nUser-Agent: python3/txupnp, UPnP/1.0, MiniUPnPc/1.9\r\nContent-Length: 341\r\nContent-Type: text/xml\r\nSOAPAction: \"urn:schemas-upnp-org:service:WANIPConnection:1#GetGenericPortMappingEntry\"\r\nConnection: Close\r\nCache-Control: no-cache\r\nPragma: no-cache\r\n\r\n\r\n4\r\n", + "response": "\n\n\t\n\t\t\n\n\n9306\nUDP\n9306\n10.0.0.94\n1\n10.0.0.94:9306 to 9306 (UDP)\n0\n\n\t\n" + }, + { + "request": "POST /soap.cgi?service=WANIPConn1 HTTP/1.1\r\nHost: 10.0.0.1:49152\r\nUser-Agent: python3/txupnp, UPnP/1.0, MiniUPnPc/1.9\r\nContent-Length: 341\r\nContent-Type: text/xml\r\nSOAPAction: \"urn:schemas-upnp-org:service:WANIPConnection:1#GetGenericPortMappingEntry\"\r\nConnection: Close\r\nCache-Control: no-cache\r\nPragma: no-cache\r\n\r\n\r\n5\r\n", + "response": "\n\n\t\n\t\t\n\t\t\ts:Client\n\t\t\tUPnPError\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t713\n\t\t\t\t\tSpecifiedArrayIndexInvalid\n\t\t\t\t\n\t\t\t\n\t\t\n\t\n" + }, + { + "request": "POST /soap.cgi?service=WANIPConn1 HTTP/1.1\r\nHost: 10.0.0.1:49152\r\nUser-Agent: python3/txupnp, UPnP/1.0, MiniUPnPc/1.9\r\nContent-Length: 598\r\nContent-Type: text/xml\r\nSOAPAction: \"urn:schemas-upnp-org:service:WANIPConnection:1#AddPortMapping\"\r\nConnection: Close\r\nCache-Control: no-cache\r\nPragma: no-cache\r\n\r\n\r\n4567UDP456710.0.0.1361txupnp test mapping\r\n", + "response": "\n\n\t\n\t\t\n\t\n" + }, + { + "request": "POST /soap.cgi?service=WANIPConn1 HTTP/1.1\r\nHost: 10.0.0.1:49152\r\nUser-Agent: python3/txupnp, UPnP/1.0, MiniUPnPc/1.9\r\nContent-Length: 379\r\nContent-Type: text/xml\r\nSOAPAction: \"urn:schemas-upnp-org:service:WANIPConnection:1#DeletePortMapping\"\r\nConnection: Close\r\nCache-Control: no-cache\r\nPragma: no-cache\r\n\r\n\r\n4567UDP\r\n", + "response": "\n\n\t\n\t\t\n\t\n" + }, + { + "request": "POST /soap.cgi?service=WANIPConn1 HTTP/1.1\r\nHost: 10.0.0.1:49152\r\nUser-Agent: python3/txupnp, UPnP/1.0, MiniUPnPc/1.9\r\nContent-Length: 403\r\nContent-Type: text/xml\r\nSOAPAction: \"urn:schemas-upnp-org:service:WANIPConnection:1#GetSpecificPortMappingEntry\"\r\nConnection: Close\r\nCache-Control: no-cache\r\nPragma: no-cache\r\n\r\n\r\nNone4567UDP\r\n", + "response": "\n\n\t\n\t\t\n\t\t\ts:Client\n\t\t\tUPnPError\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t714\n\t\t\t\t\tNoSuchEntryInArray\n\t\t\t\t\n\t\t\t\n\t\t\n\t\n" + } + ] + } + }, + "soap": [ + [ + "get_external_ip", + [], + "1.2.3.4" + ], + [ + "get_redirects", + [], + [ + [ + null, + 9308, + "UDP", + 9308, + "10.0.0.94", + true, + "10.0.0.94:9308 to 9308 (UDP)", + 0 + ], + [ + null, + 3333, + "TCP", + 3333, + "10.0.0.11", + true, + "LBRY peer port", + 0 + ], + [ + null, + 4444, + "UDP", + 4444, + "10.0.0.11", + true, + "LBRY DHT port", + 0 + ], + [ + null, + 9305, + "UDP", + 9305, + "10.0.0.94", + true, + "10.0.0.94:9305 to 9305 (UDP)", + 0 + ], + [ + null, + 9306, + "UDP", + 9306, + "10.0.0.94", + true, + "10.0.0.94:9306 to 9306 (UDP)", + 0 + ] + ] + ], + [ + "get_next_mapping", + [ + 4567, + "UDP", + "txupnp test mapping" + ], + 4567 + ], + [ + "delete_port_mapping", + [ + 4567, + "UDP" + ], + [] + ], + [ + "get_specific_port_mapping", + [ + 4567, + "UDP" + ], + null + ] + ] +} \ No newline at end of file diff --git a/tests/test_devices.py b/tests/test_devices.py index 061690f..cc9ca79 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -1,117 +1,117 @@ -from twisted.internet import reactor, defer -from twisted.trial import unittest -from txupnp.constants import SSDP_PORT, SSDP_IP_ADDRESS -from txupnp.upnp import UPnP -from txupnp.mocks import MockReactor, MockSSDPServiceGatewayProtocol, get_device_test_case - - -class TestDevice(unittest.TestCase): - manufacturer, model = "Cisco", "CGA4131COM" - - device = get_device_test_case(manufacturer, model) - router_address = device.device_dict['router_address'] - client_address = device.device_dict['client_address'] - expected_devices = device.device_dict['expected_devices'] - packets_rx = device.device_dict['ssdp']['received'] - packets_tx = device.device_dict['ssdp']['sent'] - expected_available_commands = device.device_dict['commands']['available'] - scdp_packets = device.device_dict['scpd'] - - def setUp(self): - fake_reactor = MockReactor(self.client_address, self.scdp_packets) - reactor.listenMulticast = fake_reactor.listenMulticast - self.reactor = reactor - server_protocol = MockSSDPServiceGatewayProtocol( - self.client_address, self.router_address, self.packets_rx, self.packets_tx - ) - self.server_port = self.reactor.listenMulticast(SSDP_PORT, server_protocol, interface=self.router_address) - self.server_port.transport.joinGroup(SSDP_IP_ADDRESS, interface=self.router_address) - - self.upnp = UPnP( - self.reactor, debug_ssdp=True, router_ip=self.router_address, - lan_ip=self.client_address, iface_name='mock' - ) - - def tearDown(self): - self.upnp.sspd_factory.disconnect() - self.server_port.stopListening() - - -class TestSSDP(TestDevice): - @defer.inlineCallbacks - def test_discover_device(self): - result = yield self.upnp.m_search(self.router_address, timeout=1) - self.assertEqual(len(self.expected_devices), len(result)) - self.assertEqual(len(result), 1) - self.assertDictEqual(self.expected_devices[0], result[0]) - - -class TestSCPD(TestDevice): - @defer.inlineCallbacks - def setUp(self): - fake_reactor = MockReactor(self.client_address, self.scdp_packets) - reactor.listenMulticast = fake_reactor.listenMulticast - reactor.connectTCP = fake_reactor.connectTCP - self.reactor = reactor - server_protocol = MockSSDPServiceGatewayProtocol( - self.client_address, self.router_address, self.packets_rx, self.packets_tx - ) - self.server_port = self.reactor.listenMulticast(SSDP_PORT, server_protocol, interface=self.router_address) - self.server_port.transport.joinGroup(SSDP_IP_ADDRESS, interface=self.router_address) - - self.upnp = UPnP( - self.reactor, debug_ssdp=True, router_ip=self.router_address, - lan_ip=self.client_address, iface_name='mock' - ) - yield self.upnp.discover() - - def test_parse_available_commands(self): - self.assertDictEqual(self.expected_available_commands, self.upnp.gateway.debug_commands()['available']) - - def test_parse_gateway(self): - self.assertDictEqual(self.device.device_dict['gateway_dict'], self.upnp.gateway.as_dict()) - - @defer.inlineCallbacks - def test_commands(self): - method, args, expected = self.device.device_dict['soap'][0] - command1 = getattr(self.upnp, method) - result = yield command1(*tuple(args)) - self.assertEqual(result, expected) - - method, args, expected = self.device.device_dict['soap'][1] - command2 = getattr(self.upnp, method) - result = yield command2(*tuple(args)) - result = [[i for i in r] for r in result] - self.assertListEqual(result, expected) - - method, args, expected = self.device.device_dict['soap'][2] - command3 = getattr(self.upnp, method) - result = yield command3(*tuple(args)) - self.assertEqual(result, expected) - - method, args, expected = self.device.device_dict['soap'][3] - command4 = getattr(self.upnp, method) - result = yield command4(*tuple(args)) - result = [r for r in result] - self.assertEqual(result, expected) - - method, args, expected = self.device.device_dict['soap'][4] - command5 = getattr(self.upnp, method) - result = yield command5(*tuple(args)) - self.assertEqual(result, expected) - - -class TestDDWRTSSDP(TestSSDP): - manufacturer, model = "DD-WRT", "router" - - -class TestDDWRTSCPD(TestSCPD): - manufacturer, model = "DD-WRT", "router" - - -class TestMiniUPnPMiniUPnPd(TestSSDP): - manufacturer, model = "MiniUPnP", "MiniUPnPd" - - -class TestMiniUPnPMiniUPnPdSCPD(TestSCPD): - manufacturer, model = "MiniUPnP", "MiniUPnPd" +# from twisted.internet import reactor, defer +# from twisted.trial import unittest +# from aioupnp.constants import SSDP_PORT, SSDP_IP_ADDRESS +# from aioupnp.upnp import UPnP +# from aioupnp.mocks import MockReactor, MockSSDPServiceGatewayProtocol, get_device_test_case +# +# +# class TestDevice(unittest.TestCase): +# manufacturer, model = "Cisco", "CGA4131COM" +# +# device = get_device_test_case(manufacturer, model) +# router_address = device.device_dict['router_address'] +# client_address = device.device_dict['client_address'] +# expected_devices = device.device_dict['expected_devices'] +# packets_rx = device.device_dict['ssdp']['received'] +# packets_tx = device.device_dict['ssdp']['sent'] +# expected_available_commands = device.device_dict['commands']['available'] +# scdp_packets = device.device_dict['scpd'] +# +# def setUp(self): +# fake_reactor = MockReactor(self.client_address, self.scdp_packets) +# reactor.listenMulticast = fake_reactor.listenMulticast +# self.reactor = reactor +# server_protocol = MockSSDPServiceGatewayProtocol( +# self.client_address, self.router_address, self.packets_rx, self.packets_tx +# ) +# self.server_port = self.reactor.listenMulticast(SSDP_PORT, server_protocol, interface=self.router_address) +# self.server_port.transport.joinGroup(SSDP_IP_ADDRESS, interface=self.router_address) +# +# self.upnp = UPnP( +# self.reactor, debug_ssdp=True, router_ip=self.router_address, +# lan_ip=self.client_address, iface_name='mock' +# ) +# +# def tearDown(self): +# self.upnp.sspd_factory.disconnect() +# self.server_port.stopListening() +# +# +# class TestSSDP(TestDevice): +# @defer.inlineCallbacks +# def test_discover_device(self): +# result = yield self.upnp.m_search(self.router_address, timeout=1) +# self.assertEqual(len(self.expected_devices), len(result)) +# self.assertEqual(len(result), 1) +# self.assertDictEqual(self.expected_devices[0], result[0]) +# +# +# class TestSCPD(TestDevice): +# @defer.inlineCallbacks +# def setUp(self): +# fake_reactor = MockReactor(self.client_address, self.scdp_packets) +# reactor.listenMulticast = fake_reactor.listenMulticast +# reactor.connectTCP = fake_reactor.connectTCP +# self.reactor = reactor +# server_protocol = MockSSDPServiceGatewayProtocol( +# self.client_address, self.router_address, self.packets_rx, self.packets_tx +# ) +# self.server_port = self.reactor.listenMulticast(SSDP_PORT, server_protocol, interface=self.router_address) +# self.server_port.transport.joinGroup(SSDP_IP_ADDRESS, interface=self.router_address) +# +# self.upnp = UPnP( +# self.reactor, debug_ssdp=True, router_ip=self.router_address, +# lan_ip=self.client_address, iface_name='mock' +# ) +# yield self.upnp.discover() +# +# def test_parse_available_commands(self): +# self.assertDictEqual(self.expected_available_commands, self.upnp.gateway.debug_commands()['available']) +# +# def test_parse_gateway(self): +# self.assertDictEqual(self.device.device_dict['gateway_dict'], self.upnp.gateway.as_dict()) +# +# @defer.inlineCallbacks +# def test_commands(self): +# method, args, expected = self.device.device_dict['soap'][0] +# command1 = getattr(self.upnp, method) +# result = yield command1(*tuple(args)) +# self.assertEqual(result, expected) +# +# method, args, expected = self.device.device_dict['soap'][1] +# command2 = getattr(self.upnp, method) +# result = yield command2(*tuple(args)) +# result = [[i for i in r] for r in result] +# self.assertListEqual(result, expected) +# +# method, args, expected = self.device.device_dict['soap'][2] +# command3 = getattr(self.upnp, method) +# result = yield command3(*tuple(args)) +# self.assertEqual(result, expected) +# +# method, args, expected = self.device.device_dict['soap'][3] +# command4 = getattr(self.upnp, method) +# result = yield command4(*tuple(args)) +# result = [r for r in result] +# self.assertEqual(result, expected) +# +# method, args, expected = self.device.device_dict['soap'][4] +# command5 = getattr(self.upnp, method) +# result = yield command5(*tuple(args)) +# self.assertEqual(result, expected) +# +# +# class TestDDWRTSSDP(TestSSDP): +# manufacturer, model = "DD-WRT", "router" +# +# +# class TestDDWRTSCPD(TestSCPD): +# manufacturer, model = "DD-WRT", "router" +# +# +# class TestMiniUPnPMiniUPnPd(TestSSDP): +# manufacturer, model = "MiniUPnP", "MiniUPnPd" +# +# +# class TestMiniUPnPMiniUPnPdSCPD(TestSCPD): +# manufacturer, model = "MiniUPnP", "MiniUPnPd" diff --git a/tests/test_ssdp_datagram.py b/tests/test_ssdp_datagram.py index 674735e..65bd68b 100644 --- a/tests/test_ssdp_datagram.py +++ b/tests/test_ssdp_datagram.py @@ -1,6 +1,6 @@ -from twisted.trial import unittest -from txupnp.ssdp_datagram import SSDPDatagram -from txupnp.fault import UPnPError +import unittest +from aioupnp.serialization.ssdp import SSDPDatagram +from aioupnp.fault import UPnPError class TestParseMSearchRequest(unittest.TestCase): @@ -99,3 +99,27 @@ class TestFailToParseMSearchResponseNoLocation(TestFailToParseMSearchResponseNoS 'st: urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1', 'USN: uuid:00000000-0000-0000-0000-000000000000::urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1' ]).encode() + + +class TestParseNotify(unittest.TestCase): + datagram = \ + b'NOTIFY * HTTP/1.1 \r\n' \ + b'Host: 239.255.255.250:1900\r\n' \ + b'Cache-Control: max-age=180\r\n' \ + b'Location: http://192.168.1.1:5431/dyndev/uuid:000c-29ea-247500c00068\r\n' \ + b'NT: upnp:rootdevice\r\n' \ + b'NTS: ssdp:alive\r\n' \ + b'SERVER: LINUX/2.4 UPnP/1.0 BRCM400/1.0\r\n' \ + b'USN: uuid:000c-29ea-247500c00068::upnp:rootdevice\r\n' \ + b'\r\n' + + def test_parse_notify(self): + packet = SSDPDatagram.decode(self.datagram) + self.assertTrue(packet._packet_type, packet._NOTIFY) + self.assertEqual(packet.host, '239.255.255.250:1900') + self.assertEqual(packet.cache_control, 'max-age=180') + self.assertEqual(packet.location, 'http://192.168.1.1:5431/dyndev/uuid:000c-29ea-247500c00068') + self.assertEqual(packet.nt, 'upnp:rootdevice') + self.assertEqual(packet.nts, 'ssdp:alive') + self.assertEqual(packet.server, 'LINUX/2.4 UPnP/1.0 BRCM400/1.0') + self.assertEqual(packet.usn, 'uuid:000c-29ea-247500c00068::upnp:rootdevice') diff --git a/txupnp/cli.py b/txupnp/cli.py deleted file mode 100644 index c21ab33..0000000 --- a/txupnp/cli.py +++ /dev/null @@ -1,159 +0,0 @@ -import os -import json -import argparse -import logging -from twisted.internet import reactor, defer -from txupnp.upnp import UPnP - -log = logging.getLogger("txupnp") - - -@defer.inlineCallbacks -def get_external_ip(u, *_): - ip = yield u.get_external_ip() - print(ip) - - -@defer.inlineCallbacks -def list_mappings(u, *_): - redirects = yield u.get_redirects() - ext_ip = yield u.get_external_ip() - for (ext_host, ext_port, proto, int_port, int_host, enabled, desc, lease) in redirects: - print("{}:{}/{} --> {}:{} ({}) (expires: {}) - {} ".format( - ext_host or ext_ip, ext_port, proto, int_host, int_port, "enabled" if enabled else "disabled", - "never" if not lease else lease, desc) - ) - - -@defer.inlineCallbacks -def add_mapping(u, *_): - port = 51413 - protocol = "UDP" - description = "txupnp test mapping" - ext_port = yield u.get_next_mapping(port, protocol, description) - if ext_port: - print("external port: %i to local %i/%s" % (ext_port, port, protocol)) - - -@defer.inlineCallbacks -def delete_mapping(u, *_): - port = 4567 - protocol = "UDP" - yield u.delete_port_mapping(port, protocol) - mapping = yield u.get_specific_port_mapping(port, protocol) - if mapping: - print("failed to remove mapping") - else: - print("removed mapping") - - -def _encode(x): - if isinstance(x, bytes): - return x.decode() - elif isinstance(x, Exception): - return str(x) - return x - - -@defer.inlineCallbacks -def generate_test_data(u, *_): - external_ip = yield u.get_external_ip() - redirects = yield u.get_redirects() - ext_port = yield u.get_next_mapping(4567, "UDP", "txupnp test mapping") - delete = yield u.delete_port_mapping(ext_port, "UDP") - after_delete = yield u.get_specific_port_mapping(ext_port, "UDP") - - commands_test_case = ( - ("get_external_ip", (), "1.2.3.4"), - ("get_redirects", (), redirects), - ("get_next_mapping", (4567, "UDP", "txupnp test mapping"), ext_port), - ("delete_port_mapping", (ext_port, "UDP"), delete), - ("get_specific_port_mapping", (ext_port, "UDP"), after_delete), - ) - - gateway = u.gateway - device = list(gateway.devices.values())[0] - assert device.manufacturer and device.modelName - device_path = os.path.join(os.getcwd(), "%s %s" % (device.manufacturer, device.modelName)) - commands = gateway.debug_commands() - with open(device_path, "w") as f: - f.write(json.dumps({ - "router_address": u.router_ip, - "client_address": u.lan_address, - "port": gateway.port, - "gateway_dict": gateway.as_dict(), - 'expected_devices': [ - { - 'cache_control': 'max-age=1800', - 'location': gateway.location, - 'server': gateway.server, - 'st': gateway.urn, - 'usn': gateway.usn - } - ], - 'commands': commands, - 'ssdp': u.sspd_factory.get_ssdp_packet_replay(), - 'scpd': gateway.requester.dump_packets(), - 'soap': commands_test_case - }, default=_encode, indent=2).replace(external_ip, "1.2.3.4")) - print("Generated test data! -> %s" % device_path) - - -cli_commands = { - "get_external_ip": get_external_ip, - "list_mappings": list_mappings, - "add_mapping": add_mapping, - "delete_mapping": delete_mapping, - "generate_test_data": generate_test_data, -} - - -@defer.inlineCallbacks -def run_command(found, u, command, debug_xml): - if not found: - print("failed to find gateway") - reactor.callLater(0, reactor.stop) - return - if command not in cli_commands: - print("unrecognized command: valid commands: %s" % list(cli_commands.keys())) - else: - yield cli_commands[command](u, debug_xml) - - -def main(): - import logging - log = logging.getLogger("txupnp") - handler = logging.StreamHandler() - handler.setFormatter(logging.Formatter('%(asctime)-15s-%(filename)s:%(lineno)s->%(message)s')) - log.addHandler(handler) - log.setLevel(logging.INFO) - - parser = argparse.ArgumentParser(description="upnp command line utility") - parser.add_argument(dest="command", type=str, help="debug_gateway | list_mappings | get_external_ip | add_mapping | delete_mapping") - parser.add_argument("--debug_logging", dest="debug_logging", default=False, action="store_true") - parser.add_argument("--include_igd_xml", dest="include_igd_xml", default=False, action="store_true") - args = parser.parse_args() - if args.debug_logging: - # from twisted.python import log as tx_log - # observer = tx_log.PythonLoggingObserver(loggerName="txupnp") - # observer.start() - log.setLevel(logging.DEBUG) - command = args.command - command = command.replace("-", "_") - if command not in cli_commands: - print("unrecognized command: %s is not in %s" % (command, cli_commands.keys())) - return - - def show(err): - print("error: {}".format(err)) - - u = UPnP(reactor, debug_ssdp=(command == "generate_test_data")) - d = u.discover() - d.addCallback(run_command, u, command, args.include_igd_xml) - d.addErrback(show) - d.addBoth(lambda _: reactor.callLater(0, reactor.stop)) - reactor.run() - - -if __name__ == "__main__": - main() diff --git a/txupnp/gateway.py b/txupnp/gateway.py deleted file mode 100644 index 25ff141..0000000 --- a/txupnp/gateway.py +++ /dev/null @@ -1,217 +0,0 @@ -import logging -from twisted.internet import defer -from txupnp.scpd import SCPDCommand, SCPDRequester -from txupnp.util import get_dict_val_case_insensitive, verify_return_types, BASE_PORT_REGEX, BASE_ADDRESS_REGEX -from txupnp.constants import SPEC_VERSION -from txupnp.commands import SCPDCommands - -log = logging.getLogger(__name__) - - -class CaseInsensitive: - def __init__(self, **kwargs): - not_evaluated = {} - for k, v in kwargs.items(): - if k.startswith("_"): - not_evaluated[k] = v - continue - try: - getattr(self, k) - setattr(self, k, v) - except AttributeError as err: - not_evaluated[k] = v - if not_evaluated: - log.debug("%s did not apply kwargs: %s", self.__class__.__name__, not_evaluated) - - def _get_attr_name(self, case_insensitive: str) -> str: - for k, v in self.__dict__.items(): - if k.lower() == case_insensitive.lower(): - return k - - def __getattr__(self, item): - if item in self.__dict__: - return self.__dict__[item] - for k, v in self.__class__.__dict__.items(): - if k.lower() == item.lower(): - if k not in self.__dict__: - self.__dict__[k] = v - return v - raise AttributeError(item) - - def __setattr__(self, item, value): - if item in self.__dict__: - self.__dict__[item] = value - return - to_update = None - for k, v in self.__dict__.items(): - if k.lower() == item.lower(): - to_update = k - break - self.__dict__[to_update or item] = value - - def as_dict(self) -> dict: - return { - k: v for k, v in self.__dict__.items() if not k.startswith("_") and not callable(v) - } - - -class Service(CaseInsensitive): - serviceType = None - serviceId = None - controlURL = None - eventSubURL = None - SCPDURL = None - - -class Device(CaseInsensitive): - serviceList = None - deviceList = None - deviceType = None - friendlyName = None - manufacturer = None - manufacturerURL = None - modelDescription = None - modelName = None - modelNumber = None - modelURL = None - serialNumber = None - udn = None - upc = None - presentationURL = None - iconList = None - - def __init__(self, devices, services, **kwargs): - super(Device, self).__init__(**kwargs) - if self.serviceList and "service" in self.serviceList: - new_services = self.serviceList["service"] - if isinstance(new_services, dict): - new_services = [new_services] - services.extend([Service(**service) for service in new_services]) - if self.deviceList: - for kw in self.deviceList.values(): - if isinstance(kw, dict): - d = Device(devices, services, **kw) - devices.append(d) - elif isinstance(kw, list): - for _inner_kw in kw: - d = Device(devices, services, **_inner_kw) - devices.append(d) - else: - log.warning("failed to parse device:\n%s", kw) - - -class Gateway: - def __init__(self, reactor, **kwargs): - flattened = { - k.lower(): v for k, v in kwargs.items() - } - usn = flattened["usn"] - server = flattened["server"] - location = flattened["location"] - st = flattened["st"] - - cache_control = flattened.get("cache_control") or flattened.get("cache-control") or "" - date = flattened.get("date", "") - ext = flattened.get("ext", "") - - self.usn = usn.encode() - self.ext = ext.encode() - self.server = server.encode() - self.location = location.encode() - self.cache_control = cache_control.encode() - self.date = date.encode() - self.urn = st.encode() - - self._xml_response = "" - self._service_descriptors = {} - self.base_address = BASE_ADDRESS_REGEX.findall(self.location)[0] - self.port = int(BASE_PORT_REGEX.findall(self.location)[0]) - self.spec_version = None - self.url_base = None - - self._device = None - self._devices = [] - self._services = [] - - self._reactor = reactor - self._unsupported_actions = {} - self._registered_commands = {} - self.commands = SCPDCommands() - self.requester = SCPDRequester(self._reactor) - - def as_dict(self) -> dict: - r = { - 'server': self.server.decode(), - 'urlBase': self.url_base, - 'location': self.location.decode(), - "specVersion": self.spec_version, - 'usn': self.usn.decode(), - 'urn': self.urn.decode(), - } - return r - - @defer.inlineCallbacks - def discover_commands(self): - response = yield self.requester.scpd_get(self.location.decode().split(self.base_address.decode())[1], self.base_address.decode(), self.port) - self.spec_version = get_dict_val_case_insensitive(response, SPEC_VERSION) - self.url_base = get_dict_val_case_insensitive(response, "urlbase") - if not self.url_base: - self.url_base = self.base_address.decode() - if response: - self._device = Device( - self._devices, self._services, **get_dict_val_case_insensitive(response, "device") - ) - else: - self._device = Device(self._devices, self._services) - for service_type in self.services.keys(): - service = self.services[service_type] - yield self.register_commands(service) - - @defer.inlineCallbacks - def register_commands(self, service: Service): - try: - action_list = yield self.requester.scpd_get_supported_actions(service, self.base_address.decode(), self.port) - except Exception as err: - log.exception("failed to register service %s: %s", service.serviceType, str(err)) - return - for name, inputs, outputs in action_list: - try: - command = SCPDCommand(self.requester, self.base_address, self.port, - service.controlURL.encode(), - service.serviceType.encode(), name, inputs, outputs) - current = getattr(self.commands, command.method) - if hasattr(current, "_return_types"): - command._process_result = verify_return_types(*current._return_types)(command._process_result) - setattr(command, "__doc__", current.__doc__) - setattr(self.commands, command.method, command) - self._registered_commands[command.method] = service.serviceType - log.debug("registered %s::%s", service.serviceType, command.method) - except AttributeError: - s = self._unsupported_actions.get(service.serviceType, []) - s.append(name) - self._unsupported_actions[service.serviceType] = s - log.debug("available command for %s does not have a wrapper implemented: %s %s %s", - service.serviceType, name, inputs, outputs) - - @property - def services(self) -> dict: - if not self._device: - return {} - return {service.serviceType: service for service in self._services} - - @property - def devices(self) -> dict: - if not self._device: - return {} - return {device.udn: device for device in self._devices} - - def get_service(self, service_type: str) -> Service: - for service in self._services: - if service.serviceType.lower() == service_type.lower(): - return service - - def debug_commands(self): - return { - 'available': self._registered_commands, - 'failed': self._unsupported_actions - } diff --git a/txupnp/mocks.py b/txupnp/mocks.py deleted file mode 100644 index d79cd9c..0000000 --- a/txupnp/mocks.py +++ /dev/null @@ -1,177 +0,0 @@ -import os -import json -import logging -from twisted.internet import task, defer -from twisted.internet.error import ConnectionDone -from twisted.internet.protocol import DatagramProtocol -from twisted.python.failure import Failure -from twisted.test.proto_helpers import _FakePort -from txupnp.ssdp_datagram import SSDPDatagram - -log = logging.getLogger() - - -class MockResponse: - def __init__(self, content): - self._content = content - self.headers = {} - - def content(self): - return defer.succeed(self._content) - - -class MockDevice: - def __init__(self, manufacturer, model): - self.manufacturer = manufacturer - self.model = model - device_path = os.path.join( - os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tests", "devices"), "{} {}".format(manufacturer, model) - ) - assert os.path.isfile(device_path) - with open(device_path, "r") as f: - self.device_dict = json.loads(f.read()) - - def __repr__(self): - return "MockDevice(manufacturer={}, model={})".format(self.manufacturer, self.model) - - -def get_mock_devices(): - return [ - MockDevice(path.split(" ")[0], path.split(" ")[1]) - for path in os.listdir(os.path.join(os.path.dirname(os.path.abspath(__file__)), "devices")) - if ".py" not in path and "pycache" not in path - ] - - -def get_device_test_case(manufacturer: str, model: str) -> MockDevice: - r = [ - MockDevice(path.split(" ")[0], path.split(" ")[1]) - for path in os.listdir(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tests", "devices")) - if ".py" not in path and "pycache" not in path and path.split(" ") == [manufacturer, model] - ] - return r[0] - - -class MockMulticastTransport: - def __init__(self, address, port, max_packet_size, network, protocol): - self.address = address - self.port = port - self.max_packet_size = max_packet_size - self._network = network - self._protocol = protocol - - def write(self, data, address): - if address[0] in self._network.group: - destinations = self._network.group[address[0]] - else: - destinations = address[0] - for address, dest in self._network.peers.items(): - if address[0] in destinations and dest.address != self.address: - dest._protocol.datagramReceived(data, (self.address, self.port)) - - def setTTL(self, ttl): - pass - - def joinGroup(self, address, interface=None): - group = self._network.group.get(address, []) - group.append(interface) - self._network.group[address] = group - - def leaveGroup(self, address, interface=None): - group = self._network.group.get(address, []) - if interface in group: - group.remove(interface) - self._network.group[address] = group - - -class MockTCPTransport(_FakePort): - def __init__(self, address, port, callback, mock_requests): - super().__init__((address, port)) - self._callback = callback - self._mock_requests = mock_requests - - def write(self, data): - if data.startswith(b"POST"): - for url, packets in self._mock_requests['POST'].items(): - for request_response in packets: - if data.decode() == request_response['request']: - self._callback(request_response['response'].encode()) - return - elif data.startswith(b"GET"): - for url, packets in self._mock_requests['GET'].items(): - if data.decode() == packets['request']: - self._callback(packets['response'].encode()) - return - - -class MockMulticastPort(_FakePort): - def __init__(self, protocol, remover, address, transport): - super().__init__((address, 1900)) - self.protocol = protocol - self._remover = remover - self.transport = transport - - def startListening(self, reason=None): - self.protocol.transport = self.transport - return self.protocol.startProtocol() - - def stopListening(self, reason=None): - result = self.protocol.stopProtocol() - self._remover() - return result - - -class MockNetwork: - def __init__(self): - self.peers = {} - self.group = {} - - def add_peer(self, port, protocol, interface, maxPacketSize): - transport = MockMulticastTransport(interface, port, maxPacketSize, self, protocol) - self.peers[(interface, port)] = transport - - def remove_peer(): - if self.peers.get((interface, port)): - del self.peers[(interface, port)] - return transport, remove_peer - - -class MockReactor(task.Clock): - def __init__(self, client_addr, mock_scpd_requests): - super().__init__() - self.client_addr = client_addr - self._mock_scpd_requests = mock_scpd_requests - self.network = MockNetwork() - - def listenMulticast(self, port, protocol, interface=None, maxPacketSize=8192, listenMultiple=True): - interface = interface or self.client_addr - transport, remover = self.network.add_peer(port, protocol, interface, maxPacketSize) - port = MockMulticastPort(protocol, remover, interface, transport) - port.startListening() - return port - - def connectTCP(self, host, port, factory, timeout=30, bindAddress=None): - protocol = factory.buildProtocol(host) - - def _write_and_close(data): - protocol.dataReceived(data) - protocol.connectionLost(Failure(ConnectionDone())) - - protocol.transport = MockTCPTransport(host, port, _write_and_close, self._mock_scpd_requests) - protocol.connectionMade() - - -class MockSSDPServiceGatewayProtocol(DatagramProtocol): - def __init__(self, client_addr: int, iface: str, packets_rx: list, packets_tx: list): - self.client_addr = client_addr - self.iface = iface - self.packets_tx = [SSDPDatagram.decode(packet.encode()) for packet in packets_tx] # sent by client - self.packets_rx = [((addr, port), SSDPDatagram.decode(packet.encode())) for (addr, port), packet in packets_rx] # rx by client - - def datagramReceived(self, datagram, address): - packet = SSDPDatagram.decode(datagram) - if packet.st in map(lambda p: p[1].st, self.packets_rx): # this contains one of the service types the server replied to - reply = list(filter(lambda p: p[1].st == packet.st, self.packets_rx))[0][1] - self.transport.write(reply.encode().encode(), (self.client_addr, 1900)) - else: - pass diff --git a/txupnp/scpd.py b/txupnp/scpd.py deleted file mode 100644 index d68da9b..0000000 --- a/txupnp/scpd.py +++ /dev/null @@ -1,264 +0,0 @@ -import re -import logging -from xml.etree import ElementTree -from twisted.internet.protocol import Protocol, ClientFactory -from twisted.internet import defer, error -from txupnp.constants import XML_VERSION, DEVICE, ROOT, SERVICE, ENVELOPE, BODY -from txupnp.util import etree_to_dict, flatten_keys -from txupnp.fault import handle_fault, UPnPError - -log = logging.getLogger(__name__) - -CONTENT_PATTERN = re.compile( - "(\<\?xml version=\"1\.0\"\?\>(\s*.)*|\>)".encode() -) -CONTENT_NO_XML_VERSION_PATTERN = re.compile( - "(\)".encode() -) - -XML_ROOT_SANITY_PATTERN = re.compile( - "(?i)(\{|(urn:schemas-[\w|\d]*-(com|org|net))[:|-](device|service)[:|-]([\w|\d|\:|\-|\_]*)|\}([\w|\d|\:|\-|\_]*))" -) - - -def parse_service_description(content: bytes): - if not content: - return [] - element_dict = etree_to_dict(ElementTree.fromstring(content.decode())) - service_info = flatten_keys(element_dict, "{%s}" % SERVICE) - if "scpd" not in service_info: - return [] - action_list = service_info["scpd"]["actionList"] - if not len(action_list): # it could be an empty string - return [] - result = [] - if isinstance(action_list["action"], dict): - arg_dicts = action_list["action"]['argumentList']['argument'] - if not isinstance(arg_dicts, list): # when there is one arg, ew - arg_dicts = [arg_dicts] - return [[ - action_list["action"]['name'], - [i['name'] for i in arg_dicts if i['direction'] == 'in'], - [i['name'] for i in arg_dicts if i['direction'] == 'out'] - ]] - for action in action_list["action"]: - if not action.get('argumentList'): - result.append((action['name'], [], [])) - else: - arg_dicts = action['argumentList']['argument'] - if not isinstance(arg_dicts, list): # when there is one arg, ew - arg_dicts = [arg_dicts] - result.append(( - action['name'], - [i['name'] for i in arg_dicts if i['direction'] == 'in'], - [i['name'] for i in arg_dicts if i['direction'] == 'out'] - )) - return result - - -class SCPDHTTPClientProtocol(Protocol): - def connectionMade(self): - self.response_buff = b"" - log.debug("Sending HTTP:\n%s", self.factory.packet.decode()) - self.factory.reactor.callLater(0, self.transport.write, self.factory.packet) - - def dataReceived(self, data): - self.response_buff += data - - def connectionLost(self, reason): - if reason.trap(error.ConnectionDone): - log.debug("Received HTTP:\n%s", self.response_buff.decode()) - if XML_VERSION.encode() in self.response_buff: - parsed = CONTENT_PATTERN.findall(self.response_buff) - result = b'' if not parsed else parsed[0][0] - self.factory.finished_deferred.callback(result) - else: - parsed = CONTENT_NO_XML_VERSION_PATTERN.findall(self.response_buff) - result = b'' if not parsed else XML_VERSION.encode() + b'\r\n' + parsed[0][0] - self.factory.finished_deferred.callback(result) - - -class SCPDHTTPClientFactory(ClientFactory): - protocol = SCPDHTTPClientProtocol - - def __init__(self, reactor, packet): - self.reactor = reactor - self.finished_deferred = defer.Deferred() - self.packet = packet - - def buildProtocol(self, addr): - p = self.protocol() - p.factory = self - return p - - @classmethod - def post(cls, reactor, command, **kwargs): - args = "".join("<%s>%s" % (n, kwargs.get(n), n) for n in command.param_names) - soap_body = ('\r\n%s\r\n' - '%s' % ( - XML_VERSION, command.method, command.service_id.decode(), - args, command.method)) - if "http://" in command.gateway_address.decode(): - host = command.gateway_address.decode().split("http://")[1] - else: - host = command.gateway_address.decode() - data = ( - ( - 'POST %s HTTP/1.1\r\n' - 'Host: %s\r\n' - 'User-Agent: python3/txupnp, UPnP/1.0, MiniUPnPc/1.9\r\n' - 'Content-Length: %i\r\n' - 'Content-Type: text/xml\r\n' - 'SOAPAction: \"%s#%s\"\r\n' - 'Connection: Close\r\n' - 'Cache-Control: no-cache\r\n' - 'Pragma: no-cache\r\n' - '%s' - '\r\n' - ) % ( - command.control_url.decode(), # could be just / even if it shouldn't be - host, - len(soap_body), - command.service_id.decode(), # maybe no quotes - command.method, - soap_body - ) - ).encode() - return cls(reactor, data) - - @classmethod - def get(cls, reactor, control_url: str, address: str): - if "http://" in address: - host = address.split("http://")[1] - else: - host = address - if ":" in host: - host = host.split(":")[0] - if not control_url.startswith("/"): - control_url = "/" + control_url - data = ( - ( - 'GET %s HTTP/1.1\r\n' - 'Accept-Encoding: gzip\r\n' - 'Host: %s\r\n' - '\r\n' - ) % (control_url, host) - ).encode() - return cls(reactor, data) - - -class SCPDRequester: - client_factory = SCPDHTTPClientFactory - - def __init__(self, reactor): - self._reactor = reactor - self._get_requests = {} - self._post_requests = {} - - def _save_get(self, request: bytes, response: bytes, destination: str) -> None: - self._get_requests[destination.lstrip("/")] = { - 'request': request, - 'response': response - } - - def _save_post(self, request: bytes, response: bytes, destination: str) -> None: - p = self._post_requests.get(destination.lstrip("/"), []) - p.append({ - 'request': request, - 'response': response, - }) - self._post_requests[destination.lstrip("/")] = p - - @defer.inlineCallbacks - def _scpd_get_soap_xml(self, control_url: str, address: str, service_port: int) -> bytes: - factory = self.client_factory.get(self._reactor, control_url, address) - url = address.split("http://")[1].split(":")[0] - self._reactor.connectTCP(url, service_port, factory) - xml_response_bytes = yield factory.finished_deferred - self._save_get(factory.packet, xml_response_bytes, control_url) - return xml_response_bytes - - @defer.inlineCallbacks - def scpd_post_soap(self, command, **kwargs) -> tuple: - factory = self.client_factory.post(self._reactor, command, **kwargs) - url = command.gateway_address.split(b"http://")[1].split(b":")[0] - self._reactor.connectTCP(url.decode(), command.service_port, factory) - xml_response_bytes = yield factory.finished_deferred - self._save_post( - factory.packet, xml_response_bytes, command.gateway_address.decode() + command.control_url.decode() - ) - content_dict = etree_to_dict(ElementTree.fromstring(xml_response_bytes.decode())) - envelope = content_dict[ENVELOPE] - response_body = flatten_keys(envelope[BODY], "{%s}" % command.service_id) - body = handle_fault(response_body) # raises UPnPError if there is a fault - response_key = None - for key in body: - if command.method in key: - response_key = key - break - if not response_key: - raise UPnPError("unknown response fields for %s") - response = body[response_key] - extracted_response = tuple([response[n] for n in command.returns]) - return extracted_response - - @defer.inlineCallbacks - def scpd_get_supported_actions(self, service, address: str, port: int) -> list: - xml_bytes = yield self._scpd_get_soap_xml(service.SCPDURL, address, port) - return parse_service_description(xml_bytes) - - @defer.inlineCallbacks - def scpd_get(self, control_url: str, service_address: str, service_port: int) -> dict: - xml_bytes = yield self._scpd_get_soap_xml(control_url, service_address, service_port) - xml_dict = etree_to_dict(ElementTree.fromstring(xml_bytes.decode())) - schema_key = DEVICE - root = ROOT - for k in xml_dict.keys(): - m = XML_ROOT_SANITY_PATTERN.findall(k) - if len(m) == 3 and m[1][0] and m[2][5]: - schema_key = m[1][0] - root = m[2][5] - break - flattened_xml = flatten_keys(xml_dict, "{%s}" % schema_key)[root] - return flattened_xml - - def dump_packets(self) -> dict: - return { - 'GET': self._get_requests, - 'POST': self._post_requests - } - - -class SCPDCommand: - def __init__(self, scpd_requester: SCPDRequester, gateway_address, service_port, control_url, service_id, method, - param_names, - returns): - self.scpd_requester = scpd_requester - self.gateway_address = gateway_address - self.service_port = service_port - self.control_url = control_url - self.service_id = service_id - self.method = method - self.param_names = param_names - self.returns = returns - - @staticmethod - def _process_result(*results): - """ - this method gets decorated automatically with a function that maps result types to the types - defined in the @return_types decorator - """ - return results - - @defer.inlineCallbacks - def __call__(self, **kwargs): - if set(kwargs.keys()) != set(self.param_names): - raise Exception("argument mismatch: %s vs %s" % (kwargs.keys(), self.param_names)) - response = yield self.scpd_requester.scpd_post_soap(self, **kwargs) - try: - result = self._process_result(*response) - except Exception as err: - log.error("error formatting response (%s):\n%s", err, response) - raise err - defer.returnValue(result) diff --git a/txupnp/ssdp.py b/txupnp/ssdp.py deleted file mode 100644 index c0998f1..0000000 --- a/txupnp/ssdp.py +++ /dev/null @@ -1,168 +0,0 @@ -import logging -import binascii -from twisted.internet import defer -from twisted.internet.protocol import DatagramProtocol -from txupnp.constants import UPNP_ORG_IGD, SSDP_DISCOVER, SSDP_IP_ADDRESS, SSDP_PORT, service_types -from txupnp.constants import SSDP_HOST -from txupnp.fault import UPnPError -from txupnp.ssdp_datagram import SSDPDatagram - -log = logging.getLogger(__name__) - - -class SSDPProtocol(DatagramProtocol): - def __init__(self, reactor, iface, router, ssdp_address=SSDP_IP_ADDRESS, - ssdp_port=SSDP_PORT, ttl=1, max_devices=None, debug_packets=False, - debug_sent=None, debug_received=None): - self._reactor = reactor - self._sem = defer.DeferredSemaphore(1) - self.discover_callbacks = {} - self.iface = iface - self.router = router - self.ssdp_address = ssdp_address - self.ssdp_port = ssdp_port - self.ttl = ttl - self._start = None - self.max_devices = max_devices - self.devices = [] - self.debug_packets = debug_packets - self.debug_sent = debug_sent if debug_sent is not None else [] - self.debug_received = debug_received if debug_sent is not None else [] - - def _send_m_search(self, service=UPNP_ORG_IGD): - packet = SSDPDatagram(SSDPDatagram._M_SEARCH, host=SSDP_HOST, st=service, man=SSDP_DISCOVER, mx=1) - log.debug("sending packet to %s:\n%s", SSDP_HOST, packet.encode()) - try: - msg_bytes = packet.encode().encode() - if self.debug_packets: - self.debug_sent.append(msg_bytes) - self.transport.write(msg_bytes, (self.ssdp_address, self.ssdp_port)) - except Exception as err: - log.exception("failed to write %s to %s:%i", packet.encode(), self.ssdp_address, self.ssdp_port) - raise err - - @staticmethod - def _gather(finished_deferred, max_results, results: list): - def discover_cb(packet): - if not finished_deferred.called and packet.st in service_types: - results.append(packet.as_dict()) - if len(results) >= max_results: - finished_deferred.callback(results) - - return discover_cb - - def m_search(self, address, timeout, max_devices): - # return deferred for a pending call if we have one - if address in self.discover_callbacks: - d = self.discover_callbacks[address][1] - if not d.called: # the existing deferred has already fired, make a new one - return d - - def _trap_timeout_and_return_results(err): - if err.check(defer.TimeoutError): - return self.devices - raise err - - d = defer.Deferred() - d.addTimeout(timeout, self._reactor) - d.addErrback(_trap_timeout_and_return_results) - found_cb = self._gather(d, max_devices, self.devices) - self.discover_callbacks[address] = found_cb, d - for st in service_types: - self._send_m_search(service=st) - return d - - def startProtocol(self): - self._start = self._reactor.seconds() - self.transport.setTTL(self.ttl) - self.transport.joinGroup(self.ssdp_address, interface=self.iface) - - def datagramReceived(self, datagram, address): - if address[0] == self.iface: - return - if self.debug_packets: - self.debug_received.append((address, datagram)) - try: - packet = SSDPDatagram.decode(datagram) - log.debug("decoded %s from %s:%i:\n%s", packet.get_friendly_name(), address[0], address[1], packet.encode()) - except UPnPError as err: - log.error("failed to decode SSDP packet from %s:%i: %s\npacket: %s", address[0], address[1], err, - binascii.hexlify(datagram)) - return - except Exception: - log.exception("failed to decode: %s", binascii.hexlify(datagram)) - return - if packet._packet_type == packet._OK: - log.debug("%s:%i replied to our m-search with new xml url: %s", address[0], address[1], packet.location) - # if address[0] in self.discover_callbacks and packet.location not in map(lambda p: p['location'], self.devices): - if packet.location not in map(lambda p: p['location'], self.devices): - if address[0] not in self.discover_callbacks: - self.devices.append(packet.as_dict()) - else: - self._sem.run(self.discover_callbacks[address[0]][0], packet) - else: - log.info("ignored packet from %s:%s (%s) %s", address[0], address[1], packet._packet_type, packet.location) - elif packet._packet_type == packet._NOTIFY: - log.debug("%s:%i sent us a notification (type: %s), url: %s", address[0], address[1], packet.nts, - packet.location) - - -class SSDPFactory: - def __init__(self, reactor, lan_address, router_address, debug_packets=False): - self.lan_address = lan_address - self.router_address = router_address - self._reactor = reactor - self.protocol = None - self.port = None - self.debug_packets = debug_packets - self.debug_sent = [] - self.debug_received = [] - self.server_infos = [] - - def disconnect(self): - if not self.port: - return - self.protocol.transport.leaveGroup(SSDP_IP_ADDRESS, interface=self.lan_address) - self.port.stopListening() - self.port = None - self.protocol = None - - def connect(self): - if not self.protocol: - self.protocol = SSDPProtocol(self._reactor, self.lan_address, self.router_address, - debug_packets=self.debug_packets, debug_sent=self.debug_sent, - debug_received=self.debug_received) - if not self.port: - self._reactor.addSystemEventTrigger("before", "shutdown", self.disconnect) - self.port = self._reactor.listenMulticast(self.protocol.ssdp_port, self.protocol, listenMultiple=True) - - @defer.inlineCallbacks - def m_search(self, address, timeout, max_devices): - """ - Perform a M-SEARCH (HTTP over UDP) and gather the results - - :param address: (str) address to listen for responses from - :param timeout: (int) timeout for the query - :param max_devices: (int) block until timeout or at least this many devices are found - :param service_types: (list) M-SEARCH "ST" arguments to try, if None use the defaults - :return: (list) [ (dict) { - 'server: (str) gateway os and version - 'location': (str) upnp gateway url, - 'cache-control': (str) max age, - 'date': (int) server time, - 'usn': (str) usn - }, ...] - """ - self.connect() - server_infos = yield self.protocol.m_search(address, timeout, max_devices) - for server_info in server_infos: - self.server_infos.append(server_info) - defer.returnValue(server_infos) - - def get_ssdp_packet_replay(self) -> dict: - return { - 'lan_address': self.lan_address, - 'router_address': self.router_address, - 'sent': self.debug_sent, - 'received': self.debug_received, - } diff --git a/txupnp/upnp.py b/txupnp/upnp.py deleted file mode 100644 index 852b3b6..0000000 --- a/txupnp/upnp.py +++ /dev/null @@ -1,146 +0,0 @@ -import netifaces -import logging -from twisted.internet import defer -from txupnp.fault import UPnPError -from txupnp.ssdp import SSDPFactory -from txupnp.gateway import Gateway - -log = logging.getLogger(__name__) - - -class UPnP: - def __init__(self, reactor, try_miniupnpc_fallback=False, debug_ssdp=False, router_ip=None, - lan_ip=None, iface_name=None): - self._reactor = reactor - if router_ip and lan_ip and iface_name: - self.router_ip, self.lan_address, self.iface_name = router_ip, lan_ip, iface_name - else: - self.router_ip, self.iface_name = netifaces.gateways()['default'][netifaces.AF_INET] - self.lan_address = netifaces.ifaddresses(self.iface_name)[netifaces.AF_INET][0]['addr'] - self.sspd_factory = SSDPFactory(self._reactor, self.lan_address, self.router_ip, debug_packets=debug_ssdp) - self.try_miniupnpc_fallback = try_miniupnpc_fallback - self.miniupnpc_runner = None - self.miniupnpc_igd_url = None - self.gateway = None - - def m_search(self, address, timeout=1, max_devices=1): - """ - Perform a HTTP over UDP M-SEARCH query - - returns (list) [{ - 'server: - 'location': , - 'cache-control': , - 'date': , - 'usn': - }, ...] - """ - return self.sspd_factory.m_search(address, timeout=timeout, max_devices=max_devices) - - @defer.inlineCallbacks - def _discover(self, timeout=1, max_devices=1): - server_infos = yield self.sspd_factory.m_search( - self.router_ip, timeout=timeout, max_devices=max_devices - ) - if not server_infos: - return False - server_info = server_infos[0] - if 'st' in server_info: - gateway = Gateway(reactor=self._reactor, **server_info) - yield gateway.discover_commands() - self.gateway = gateway - return True - elif 'st' not in server_info: - log.error("don't know how to handle gateway: %s", server_info) - return False - - @defer.inlineCallbacks - def discover(self, timeout=1, max_devices=1): - try: - found = yield self._discover(timeout=timeout, max_devices=max_devices) - except defer.TimeoutError: - found = False - finally: - self.sspd_factory.disconnect() - if found: - log.debug("found upnp device") - else: - log.debug("failed to find upnp device") - return found - - def get_external_ip(self) -> str: - return self.gateway.commands.GetExternalIPAddress() - - def add_port_mapping(self, external_port: int, protocol: str, internal_port, lan_address: str, - description: str) -> None: - return self.gateway.commands.AddPortMapping( - NewRemoteHost="", NewExternalPort=external_port, NewProtocol=protocol, - NewInternalPort=internal_port, NewInternalClient=lan_address, - NewEnabled=1, NewPortMappingDescription=description, NewLeaseDuration="" - ) - - @defer.inlineCallbacks - def get_port_mapping_by_index(self, index: int) -> (str, int, str, int, str, bool, str, int): - try: - redirect = yield self.gateway.commands.GetGenericPortMappingEntry(NewPortMappingIndex=index) - defer.returnValue(redirect) - except UPnPError: - defer.returnValue(None) - - @defer.inlineCallbacks - def get_redirects(self): - redirects = [] - cnt = 0 - redirect = yield self.get_port_mapping_by_index(cnt) - while redirect: - redirects.append(redirect) - cnt += 1 - redirect = yield self.get_port_mapping_by_index(cnt) - defer.returnValue(redirects) - - @defer.inlineCallbacks - def get_specific_port_mapping(self, external_port: int, protocol: str) -> (int, str, bool, str, int): - """ - :param external_port: (int) external port to listen on - :param protocol: (str) 'UDP' | 'TCP' - :return: (int) , (str) , (bool) , (str) , (int) - """ - - try: - result = yield self.gateway.commands.GetSpecificPortMappingEntry( - NewRemoteHost=None, NewExternalPort=external_port, NewProtocol=protocol - ) - defer.returnValue(result) - except UPnPError: - defer.returnValue(None) - - def delete_port_mapping(self, external_port: int, protocol: str) -> None: - """ - :param external_port: (int) external port to listen on - :param protocol: (str) 'UDP' | 'TCP' - :return: None - """ - return self.gateway.commands.DeletePortMapping( - NewRemoteHost="", NewExternalPort=external_port, NewProtocol=protocol - ) - - @defer.inlineCallbacks - def get_next_mapping(self, port, protocol, description, internal_port=None): - if protocol not in ["UDP", "TCP"]: - raise UPnPError("unsupported protocol: {}".format(protocol)) - internal_port = internal_port or port - redirect_tups = yield self.get_redirects() - redirects = { - "%i:%s" % (ext_port, proto): (int_host, int_port, desc) - for (ext_host, ext_port, proto, int_port, int_host, enabled, desc, lease) in redirect_tups - } - while ("%i:%s" % (port, protocol)) in redirects: - int_host, int_port, _ = redirects["%i:%s" % (port, protocol)] - if int_host == self.lan_address and int_port == internal_port: - break - port += 1 - - yield self.add_port_mapping( # set one up - port, protocol, internal_port, self.lan_address, description - ) - defer.returnValue(port)