# HG changeset patch # User Goffi # Date 1446498161 -3600 # Node ID e281ed2c21dbc33ea6339f599d6db94656d4d6d0 # Parent ebf97c1ac14ad59b6c803269764bee7e0694ae14 plugin NAT port: added UPnP IGD mapping + automatic unmapping on backend shut down diff -r ebf97c1ac14a -r e281ed2c21db src/plugins/plugin_misc_nat-port.py --- a/src/plugins/plugin_misc_nat-port.py Mon Nov 02 22:02:41 2015 +0100 +++ b/src/plugins/plugin_misc_nat-port.py Mon Nov 02 22:02:41 2015 +0100 @@ -24,6 +24,8 @@ from sat.core import exceptions from twisted.internet import threads from twisted.internet import defer +from twisted.python import failure +import threading try: import miniupnpc @@ -40,6 +42,13 @@ "description": _("""Automatic NAT port mapping using UPnP"""), } +STARTING_PORT = 6000 # starting point to automatically find a port +DEFAULT_DESC = u'SaT port mapping' # we don't use "à" here as some bugged NAT don't manage charset correctly + + +class MappingError(Exception): + pass + class NatPort(object): # TODO: refresh data if a new connection is detected (see plugin_misc_ip) @@ -51,12 +60,24 @@ self._initialised = defer.Deferred() self._upnp = miniupnpc.UPnP() # will be None if no device is available self._upnp.discoverdelay=200 + self._mutex = threading.Lock() # used to protect access to self._upnp + self._starting_port_cache = None # used to cache the first available port + self._to_unmap = [] # list of tuples (ext_port, protocol) of ports to unmap on unload discover_d = threads.deferToThread(self._discover) discover_d.chainDeferred(self._initialised) self._initialised.addErrback(self._init_failed) - def _init_failed(self, failure): - log.info(u"UPnP-GID not available") + def unload(self): + if self._to_unmap: + log.info(u"Cleaning mapped ports") + return threads.deferToThread(self._unmapPortsBlocking) + + def _init_failed(self, failure_): + e = failure_.trap(exceptions.NotFound, exceptions.FeatureNotFound) + if e == exceptions.FeatureNotFound: + log.info(u"UPnP-IGD seems to be not activated on the device") + else: + log.info(u"UPnP-IGD not available") self._upnp = None def _discover(self): @@ -65,9 +86,12 @@ log.info(u"{nb} UPnP-IGD device(s) found".format(nb=devices)) else: log.info(u"Can't find UPnP-IGD device on the local network") - raise exceptions.NotFound + raise failure.Failure(exceptions.NotFound()) self._upnp.selectigd() - self._external_ip = self._upnp.externalipaddress() + try: + self._external_ip = self._upnp.externalipaddress() + except Exception: + raise failure.Failure(exceptions.FeatureNotFound()) def getIP(self, local=False): """Return IP address found with UPnP-IGD @@ -82,3 +106,85 @@ # we need to return None in this case return (self._upnp.lanaddr or None) if local else self._external_ip return self._initialised.addCallback(getIP) + + def _unmapPortsBlocking(self): + """Unmap ports mapped in this session""" + self._mutex.acquire() + try: + for port, protocol in self._to_unmap: + log.info(u"Unmapping port {}".format(port)) + unmapping = self._upnp.deleteportmapping( + # the last parameter is remoteHost, we don't use it + port, protocol, '') + + if not unmapping: + log.error(u"Can't unmap port {port} ({protocol})".format( + port=port, protocol=protocol)) + del self._to_unmap[:] + finally: + self._mutex.release() + + def _mapPortBlocking(self, int_port, ext_port, protocol, desc): + """Internal blocking method to map port + + @param int_port(int): internal port to use + @param ext_port(int): external port to use, or None to find one automatically + @param protocol(str): 'TCP' or 'UDP' + @param desc(str): description of the mapping + @param return(int, None): external port used in case of success, otherwise None + """ + # we use mutex to avoid race condition if 2 threads + # try to acquire a port at the same time + self._mutex.acquire() + try: + if ext_port is None: + # find a free port + starting_port = self._starting_port_cache + ext_port = STARTING_PORT if starting_port is None else starting_port + ret = self._upnp.getspecificportmapping(ext_port, protocol) + while ret != None and ext_port < 65536: + ext_port += 1 + ret = self._upnp.getspecificportmapping(ext_port, protocol) + if starting_port is None: + # XXX: we cache the first successfuly found external port + # to avoid testing again the first series the next time + self._starting_port_cache = ext_port + + mapping = self._upnp.addportmapping( + # the last parameter is remoteHost, we don't use it + ext_port, protocol, self._upnp.lanaddr, int_port, desc, '') + + if not mapping: + raise MappingError + else: + self._to_unmap.append((ext_port, protocol)) + finally: + self._mutex.release() + + return ext_port + + def mapPort(self, int_port, ext_port=None, protocol='TCP', desc=DEFAULT_DESC): + """Add a port mapping + + @param int_port(int): internal port to use + @param ext_port(int,None): external port to use, or None to find one automatically + @param protocol(str): 'TCP' or 'UDP' + @param desc(unicode): description of the mapping + Some UPnP IGD devices have broken encoding. It's probably a good idea to avoid non-ascii chars here + @return (D(int, None)): external port used in case of success, otherwise None + """ + if self._upnp is None: + return defer.succeed(None) + def mappingCb(ext_port): + log.info(u"{protocol} mapping from {int_port} to {ext_port} successful".format( + protocol = protocol, + int_port = int_port, + ext_port = ext_port, + )) + return ext_port + def mappingEb(failure): + failure.trap(MappingError) + log.warning(u"Can't map internal {int_port}".format(int_port=int_port)) + d = threads.deferToThread(self._mapPortBlocking, int_port, ext_port, protocol, desc) + d.addCallbacks(mappingCb, mappingEb) + return d