changeset 1554:e281ed2c21db

plugin NAT port: added UPnP IGD mapping + automatic unmapping on backend shut down
author Goffi <goffi@goffi.org>
date Mon, 02 Nov 2015 22:02:41 +0100
parents ebf97c1ac14a
children eb8aae35085b
files src/plugins/plugin_misc_nat-port.py
diffstat 1 files changed, 110 insertions(+), 4 deletions(-) [+]
line wrap: on
line diff
--- 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