upnp port forwarding
authorForrest Voight <forrest@forre.st>
Wed, 3 Aug 2011 06:30:28 +0000 (02:30 -0400)
committerForrest Voight <forrest@forre.st>
Wed, 3 Aug 2011 06:30:28 +0000 (02:30 -0400)
nattraverso/__init__.py [new file with mode: 0644]
nattraverso/ipdiscover.py [new file with mode: 0644]
nattraverso/portmapper.py [new file with mode: 0644]
nattraverso/pynupnp/__init__.py [new file with mode: 0644]
nattraverso/pynupnp/soap.py [new file with mode: 0644]
nattraverso/pynupnp/upnp.py [new file with mode: 0644]
nattraverso/pynupnp/upnpxml.py [new file with mode: 0644]
nattraverso/utils.py [new file with mode: 0644]
p2pool/main.py

diff --git a/nattraverso/__init__.py b/nattraverso/__init__.py
new file mode 100644 (file)
index 0000000..fe3df53
--- /dev/null
@@ -0,0 +1,15 @@
+"""
+This package offers ways to retreive ip addresses of the machine, and map ports
+through various protocols.
+
+Currently only UPnP is implemented and available, in the pynupnp module.
+
+@author: Raphael Slinckx
+@copyright: Copyright 2005
+@license: LGPL
+@contact: U{raphael@slinckx.net<mailto:raphael@slinckx.net>}
+@version: 0.1.0
+"""
+
+__revision__ = "$id"
+__version__ = "0.1.0"
diff --git a/nattraverso/ipdiscover.py b/nattraverso/ipdiscover.py
new file mode 100644 (file)
index 0000000..f8b5012
--- /dev/null
@@ -0,0 +1,207 @@
+"""
+Generic methods to retreive the IP address of the local machine.
+
+TODO: Example
+
+@author: Raphael Slinckx
+@copyright: Copyright 2005
+@license: LGPL
+@contact: U{raphael@slinckx.net<mailto:raphael@slinckx.net>}
+@version: 0.1.0
+"""
+
+__revision__ = "$id"
+
+import random, socket, logging
+
+from twisted.internet import defer, reactor
+
+from twisted.internet.protocol import DatagramProtocol
+from twisted.internet.error import CannotListenError
+from twisted.internet.interfaces import IReactorMulticast
+
+from nattraverso.utils import is_rfc1918_ip, is_bogus_ip
+
+def get_local_ip():
+       """
+       Returns a deferred which will be called with a
+       2-uple (lan_flag, ip_address) :
+               - lan_flag:
+                       - True if it's a local network (RFC1918)
+                       - False if it's a WAN address
+                             
+               - ip_address is the actual ip address
+               
+       @return: A deferred called with the above defined tuple
+       @rtype: L{twisted.internet.defer.Deferred}
+       """
+       # first we try a connected udp socket, then via multicast
+       logging.debug("Resolving dns to get udp ip")
+       result = reactor.resolve('A.ROOT-SERVERS.NET')
+       result.addCallbacks(_get_via_connected_udp, lambda x:_get_via_multicast())
+       return result
+
+def get_external_ip(): 
+       """
+       Returns a deferred which will be called with a
+       2-uple (wan_flag, ip_address):
+               - wan_flag:
+                       - True if it's a WAN address
+                       - False if it's a LAN address
+                       - None if it's a localhost (127.0.0.1) address
+               - ip_address: the most accessible ip address of this machine
+       
+       @return: A deferred called with the above defined tuple
+       @rtype: L{twisted.internet.defer.Deferred}
+       """
+       return get_local_ip().addCallbacks(_on_local_ip, _on_no_local_ip)
+       
+#Private----------
+def _on_upnp_external_found(ipaddr):
+       """
+       Called when an external ip is found through UPNP.
+       
+       @param ipaddr: The WAN ip address
+       @type ipaddr: an IP string "x.x.x.x"
+       """
+       return (True, ipaddr)
+
+def _on_no_upnp_external_found(error, ipaddr):
+       """
+       Called when the UPnP device failed to return external address.
+       
+       @param ipaddr: The LAN ip address
+       @type ipaddr: an IP string "x.x.x.x"
+       """
+       return (False, ipaddr)
+       
+def _on_local_ip(result):
+       """
+       Called when we got the local ip of this machine. If we have a WAN address,
+       we return immediately, else we try to discover ip address through UPnP.
+       
+       @param result: a tuple (lan_flag, ip_addr)
+       @type result: a tuple (bool, ip string)
+       """
+       local, ipaddr = result
+       if not local:
+               return (True, ipaddr)
+       else:
+               logging.debug("Got local ip, trying to use upnp to get WAN ip")
+               import nattraverso.pynupnp
+               return nattraverso.pynupnp.get_external_ip().addCallbacks(
+                       _on_upnp_external_found,
+                       lambda x: _on_no_upnp_external_found(x, ipaddr))
+
+def _on_no_local_ip(error):
+       """
+       Called when we could not retreive by any mean the ip of this machine.
+       We simply assume there is no connectivity, and return localhost address.
+       """
+       return (None, "127.0.0.1")
+       
+def _got_multicast_ip(ipaddr):
+       """
+       Called when we received the ip address via udp multicast.
+       
+       @param ipaddr: an ip address
+       @type ipaddr: a string "x.x.x.x"
+       """
+       return (is_rfc1918_ip(ipaddr), ipaddr)
+
+def _get_via_multicast():
+       """
+       Init a multicast ip address discovery.
+       
+       @return: A deferred called with the discovered ip address
+       @rtype: L{twisted.internet.defer.Deferred}
+       @raise Exception: When an error occurs during the multicast engine init
+       """
+       try:
+               # Init multicast engine
+               IReactorMulticast(reactor)
+       except:
+               raise
+
+       logging.debug("Multicast ping to retreive local IP")
+       return LocalNetworkMulticast().discover().addCallback(_got_multicast_ip)
+
+def _get_via_connected_udp(ipaddr):
+       """
+       Init a UDP socket ip discovery. We do a dns query, and retreive our
+       ip address from the connected udp socket.
+       
+       @param ipaddr: The ip address of a dns server
+       @type ipaddr: a string "x.x.x.x"
+       @raise RuntimeError: When the ip is a bogus ip (0.0.0.0 or alike)
+       """
+       udpprot = DatagramProtocol()
+       port = reactor.listenUDP(0, udpprot)
+       udpprot.transport.connect(ipaddr, 7)
+       localip = udpprot.transport.getHost().host
+       port.stopListening()
+       
+       if is_bogus_ip(localip):
+               raise RuntimeError, "Invalid IP addres returned"
+       else:
+               return (is_rfc1918_ip(localip), localip)
+               
+class LocalNetworkMulticast(DatagramProtocol, object):
+       """
+       Local IP discovery protocol via multicast:
+               - Broadcast 3 ping multicast packet with "ping" in it
+               - Wait for an answer
+               - Retreive the ip address from the returning packet, which is ours
+       """
+       def __init__(self, *args, **kwargs):
+               super(LocalNetworkMulticast, self).__init__(*args, **kwargs)
+               
+               # Nothing found yet
+               self.completed = False
+               self.result = defer.Deferred()
+               
+       def discover(self):
+               """
+               Launch the discovery of an UPnP device on the local network. You should
+               always call this after having created the object.
+               
+               >>> result = LocalNetworkMulticast().discover().addCallback(got_upnpdevice_ip)
+               
+               @return: A deferred called with the IP address of the UPnP device
+               @rtype: L{twisted.internet.defer.Deferred}
+               """
+               #The port we listen on
+               mcast_port = 0
+               #The result of listenMulticast
+               mcast = None
+
+               # 5 different UDP ports
+               ports = [11000+random.randint(0, 5000) for port in range(5)]
+               for attempt, port in enumerate(ports):
+                       try:
+                               mcast = reactor.listenMulticast(port, self)
+                               mcast_port = port
+                               break
+                       except CannotListenError:
+                               if attempt < 5:
+                                       print "Trying another multicast UDP port", port
+                               else:
+                                       raise
+               
+               logging.debug("Sending multicast ping")
+               mcast.joinGroup('239.255.255.250', socket.INADDR_ANY)
+               self.transport.write('ping', ('239.255.255.250', mcast_port))
+               self.transport.write('ping', ('239.255.255.250', mcast_port))
+               self.transport.write('ping', ('239.255.255.250', mcast_port))
+               return self.result
+
+       def datagramReceived(self, dgram, addr):
+               """Datagram received, we callback the IP address."""
+               logging.debug("Received multicast pong: %s; addr:%r", dgram, addr)
+               if self.completed or dgram != 'ping':
+                       # Result already handled, do nothing
+                       return
+               else:
+                       self.completed = True
+                       # Return the upn device ip address
+                       self.result.callback(addr[0])
diff --git a/nattraverso/portmapper.py b/nattraverso/portmapper.py
new file mode 100644 (file)
index 0000000..2cee267
--- /dev/null
@@ -0,0 +1,118 @@
+"""
+Generic NAT Port mapping interface.
+
+TODO: Example
+
+@author: Raphael Slinckx
+@copyright: Copyright 2005
+@license: LGPL
+@contact: U{raphael@slinckx.net<mailto:raphael@slinckx.net>}
+@version: 0.1.0
+"""
+
+__revision__ = "$id"
+
+from twisted.internet.base import BasePort
+
+# Public API
+def get_port_mapper(proto="TCP"):
+       """
+       Returns a L{NATMapper} instance, suited to map a port for
+       the given protocol. Defaults to TCP.
+       
+       For the moment, only upnp mapper is available. It accepts both UDP and TCP.
+       
+       @param proto: The protocol: 'TCP' or 'UDP'
+       @type proto: string
+       @return: A deferred called with a L{NATMapper} instance
+       @rtype: L{twisted.internet.defer.Deferred}
+       """
+       import nattraverso.pynupnp
+       return nattraverso.pynupnp.get_port_mapper()
+       
+class NATMapper:
+       """
+       Define methods to map port objects (as returned by twisted's listenXX).
+       This allows NAT to be traversed from incoming packets.
+       
+       Currently the only implementation of this class is the UPnP Mapper, which
+       can map UDP and TCP ports, if an UPnP Device exists.
+       """
+       def __init__(self):
+               raise NotImplementedError("Cannot instantiate the class")
+               
+       def map(self, port):
+               """
+               Create a mapping for the given twisted's port object.
+               
+               The deferred will call back with a tuple (extaddr, extport):
+                       - extaddr: The ip string of the external ip address of this host
+                       - extport: the external port number used to map the given Port object
+               
+               When called multiple times with the same Port,
+               callback with the existing mapping.
+               
+               @param port: The port object to map
+               @type port: a L{twisted.internet.interfaces.IListeningPort} object
+               @return: A deferred called with the above defined tuple
+               @rtype: L{twisted.internet.defer.Deferred}
+               """
+               raise NotImplementedError
+
+       def info(self, port):
+               """
+               Returns the existing mapping for the given port object. That means map()
+               has to be called before.
+               
+               @param port: The port object to retreive info from
+               @type port: a L{twisted.internet.interfaces.IListeningPort} object
+               @raise ValueError: When there is no such existing mapping
+               @return: a tuple (extaddress, extport).
+               @see: L{map() function<map>}
+               """
+               raise NotImplementedError
+
+       def unmap(self, port):
+               """
+               Remove an existing mapping for the given twisted's port object.
+               
+               @param port: The port object to unmap
+               @type port: a L{twisted.internet.interfaces.IListeningPort} object
+               @return: A deferred called with None
+               @rtype: L{twisted.internet.defer.Deferred}
+               @raise ValueError: When there is no such existing mapping
+               """
+               raise NotImplementedError
+       
+       def get_port_mappings(self):
+               """
+               Returns a deferred that will be called with a dictionnary of the
+               existing mappings.
+               
+               The dictionnary structure is the following:
+                       - Keys: tuple (protocol, external_port)
+                               - protocol is "TCP" or "UDP".
+                               - external_port is the external port number, as see on the
+                                       WAN side.
+                       - Values:tuple (internal_ip, internal_port)
+                               - internal_ip is the LAN ip address of the host.
+                               - internal_port is the internal port number mapped
+                                       to external_port.
+               
+               @return: A deferred called with the above defined dictionnary
+               @rtype: L{twisted.internet.defer.Deferred}
+               """
+               raise NotImplementedError
+               
+       def _check_valid_port(self, port):
+               """Various Port object validity checks. Raise a ValueError."""
+               if not isinstance(port, BasePort):
+                       raise ValueError("expected a Port, got %r"%(port))
+
+               if not port.connected:
+                       raise ValueError("Port %r is not listening"%(port))
+
+               loc_addr = port.getHost()
+               if loc_addr.port == 0:
+                       raise ValueError("Port %r has port number of 0"%(port))
+       
diff --git a/nattraverso/pynupnp/__init__.py b/nattraverso/pynupnp/__init__.py
new file mode 100644 (file)
index 0000000..2db28dd
--- /dev/null
@@ -0,0 +1,33 @@
+"""
+This package offers ways to retreive ip addresses of the machine, and map ports
+through UPnP devices.
+
+@author: Raphael Slinckx
+@copyright: Copyright 2005
+@license: LGPL
+@contact: U{raphael@slinckx.net<mailto:raphael@slinckx.net>}
+@version: 0.1.0
+"""
+__revision__ = "$id"
+
+from nattraverso.pynupnp.upnp import search_upnp_device, UPnPMapper
+
+def get_external_ip():
+       """
+       Returns a deferred which will be called with the WAN ip address
+       retreived through UPnP. The ip is a string of the form "x.x.x.x"
+       
+       @return: A deferred called with the external ip address of this host
+       @rtype: L{twisted.internet.defer.Deferred}
+       """
+       return search_upnp_device().addCallback(lambda x: x.get_external_ip())
+
+def get_port_mapper():
+       """
+       Returns a deferred which will be called with a L{UPnPMapper} instance.
+       This is a L{nattraverso.portmapper.NATMapper} implementation.
+       
+       @return: A deferred called with the L{UPnPMapper} instance.
+       @rtype: L{twisted.internet.defer.Deferred}
+       """
+       return search_upnp_device().addCallback(lambda x: UPnPMapper(x))
diff --git a/nattraverso/pynupnp/soap.py b/nattraverso/pynupnp/soap.py
new file mode 100644 (file)
index 0000000..e3dc555
--- /dev/null
@@ -0,0 +1,104 @@
+"""
+This module is a SOAP client using twisted's deferreds.
+It uses the SOAPpy package.
+
+@author: Raphael Slinckx
+@copyright: Copyright 2005
+@license: LGPL
+@contact: U{raphael@slinckx.net<mailto:raphael@slinckx.net>}
+@version: 0.1.0
+"""
+
+__revision__ = "$id"
+
+import SOAPpy, logging
+from SOAPpy.Config import Config
+from twisted.web import client, error
+
+#General config
+Config.typed = False
+
+class SoapError(Exception):
+       """
+       This is a SOAP error message, not an HTTP error message.
+       
+       The content of this error is a SOAPpy structure representing the
+       SOAP error message.
+       """
+       pass
+
+class SoapProxy:
+       """
+       Proxy for an url to which we send SOAP rpc calls.
+       """
+       def __init__(self, url, prefix):
+               """
+               Init the proxy, it will connect to the given url, using the
+               given soap namespace.
+               
+               @param url: The url of the remote host to call
+               @param prefix: The namespace prefix to use, eg.
+                       'urn:schemas-upnp-org:service:WANIPConnection:1'
+               """
+               logging.debug("Soap Proxy: '%s', prefix: '%s'", url, prefix)
+               self._url = url
+               self._prefix = prefix
+               
+       def call(self, method, **kwargs):
+               """
+               Call the given remote method with the given arguments, as keywords.
+               
+               Returns a deferred, called with SOAPpy structure representing
+               the soap response.
+               
+               @param method: The method name to call, eg. 'GetExternalIP'
+               @param kwargs: The parameters of the call, as keywords
+               @return: A deferred called with the external ip address of this host
+               @rtype: L{twisted.internet.defer.Deferred}
+               """
+               payload = SOAPpy.buildSOAP(method=method, config=Config, namespace=self._prefix, kw=kwargs)
+               # Here begins the nasty hack
+               payload = payload.replace(
+                                       # Upnp wants s: instead of SOAP-ENV
+                                       'SOAP-ENV','s').replace(
+                                       # Doesn't seem to like these encoding stuff
+                                       'xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/"', '').replace(
+                                       'SOAP-ENC:root="1"', '').replace(
+                                       # And it wants u: instead of ns1 namespace for arguments..
+                                       'ns1','u')
+
+               logging.debug("SOAP Payload:\n%s", payload)
+               
+               return client.getPage(self._url, postdata=payload, method="POST",
+                       headers={'content-type': 'text/xml',    'SOAPACTION': '%s#%s' % (self._prefix, method)}
+        ).addCallbacks(self._got_page, self._got_error)
+
+       def _got_page(self, result):
+               """
+               The http POST command was successful, we parse the SOAP
+               answer, and return it.
+               
+               @param result: the xml content
+               """
+               parsed = SOAPpy.parseSOAPRPC(result)
+               
+               logging.debug("SOAP Answer:\n%s", result)
+               logging.debug("SOAP Parsed Answer: %r", parsed)
+               
+               return parsed
+                               
+       def _got_error(self, res):
+               """
+               The HTTP POST command did not succeed, depending on the error type:
+                       - it's a SOAP error, we parse it and return a L{SoapError}.
+                       - it's another type of error (http, other), we raise it as is
+               """
+               logging.debug("SOAP Error:\n%s", res)
+               
+               if isinstance(res.value, error.Error):
+                       try:
+                               logging.debug("SOAP Error content:\n%s", res.value.response)
+                               raise SoapError(SOAPpy.parseSOAPRPC(res.value.response)["detail"])
+                       except:
+                               raise
+               raise Exception(res.value)
diff --git a/nattraverso/pynupnp/upnp.py b/nattraverso/pynupnp/upnp.py
new file mode 100644 (file)
index 0000000..ade388d
--- /dev/null
@@ -0,0 +1,544 @@
+"""
+This module is the heart of the upnp support. Device discover, ip discovery
+and port mappings are implemented here.
+
+@author: Raphael Slinckx
+@author: Anthony Baxter
+@copyright: Copyright 2005
+@license: LGPL
+@contact: U{raphael@slinckx.net<mailto:raphael@slinckx.net>}
+@version: 0.1.0
+"""
+__revision__ = "$id"
+
+import socket, random, urlparse, logging
+
+from twisted.internet import reactor, defer
+from twisted.web import client
+from twisted.internet.protocol import DatagramProtocol
+from twisted.internet.error import CannotListenError
+
+from nattraverso.pynupnp.soap import SoapProxy
+from nattraverso.pynupnp.upnpxml import UPnPXml
+from nattraverso import ipdiscover, portmapper
+
+class UPnPError(Exception):
+       """
+       A generic UPnP error, with a descriptive message as content.
+       """
+       pass
+
+def search_upnp_device ():
+       """
+       Check the network for an UPnP device. Returns a deferred
+       with the L{UPnPDevice} instance as result, if found.
+       
+       @return: A deferred called with the L{UPnPDevice} instance
+       @rtype: L{twisted.internet.defer.Deferred}
+       """
+       try:
+               return UPnPProtocol().search_device()
+       except Exception, msg:
+               return defer.fail(UPnPError(msg))
+
+class UPnPMapper(portmapper.NATMapper):
+       """
+       This is the UPnP port mapper implementing the
+       L{NATMapper<portmapper.NATMapper>} interface.
+       
+       @see: L{NATMapper<portmapper.NATMapper>}
+       """
+       
+       def __init__(self, upnp):
+               """
+               Creates the mapper, with the given L{UPnPDevice} instance.
+               
+               @param upnp: L{UPnPDevice} instance
+               """
+               self._mapped = {}
+               self._upnp = upnp
+               
+       def map(self, port):
+               """
+               See interface
+               """
+               self._check_valid_port(port)
+
+               #Port is already mapped
+               if port in self._mapped:
+                       return defer.succeed(self._mapped[port])
+               
+               #Trigger a new mapping creation, first fetch local ip.
+               result = ipdiscover.get_local_ip()
+               self._mapped[port] = result
+               return result.addCallback(self._map_got_local_ip, port)
+       
+       def info(self, port):
+               """
+               See interface
+               """
+               # If the mapping exists, everything's ok
+               if port in self._mapped:
+                       return self._mapped[port]
+               else:
+                       raise ValueError('Port %r is not currently mapped'%(port))
+
+       def unmap(self, port):
+               """
+               See interface
+               """
+               if port in self._mapped:
+                       existing = self._mapped[port]
+                       
+                       #Pending mapping, queue an unmap,return existing deferred
+                       if type(existing) is not tuple:
+                               existing.addCallback(lambda x: self.unmap(port))
+                               return existing
+                       
+                       #Remove our local mapping
+                       del self._mapped[port]
+                       
+                       #Ask the UPnP to remove the mapping
+                       extaddr, extport = existing
+                       return self._upnp.remove_port_mapping(extport, port.getHost().type)
+               else:
+                       raise ValueError('Port %r is not currently mapped'%(port))
+       
+       def get_port_mappings(self):
+               """
+               See interface
+               """
+               return self._upnp.get_port_mappings()
+               
+       def _map_got_local_ip(self, ip_result, port):
+               """
+               We got the local ip address, retreive the existing port mappings
+               in the device.
+               
+               @param ip_result: result of L{ipdiscover.get_local_ip}
+               @param port: a L{twisted.internet.interfaces.IListeningPort} we
+                       want to map
+               """
+               local, ip = ip_result
+               return self._upnp.get_port_mappings().addCallback(
+                                                                               self._map_got_port_mappings, ip, port)
+               
+       def _map_got_port_mappings(self, mappings, ip, port):
+               """
+               We got all the existing mappings in the device, find an unused one
+               and assign it for the requested port.
+               
+               @param ip: The local ip of this host "x.x.x.x"
+               @param port: a L{twisted.internet.interfaces.IListeningPort} we
+                       want to map
+               @param mappings: result of L{UPnPDevice.get_port_mappings}
+               """
+
+               #Get the requested mapping's info
+               ptype = port.getHost().type
+               intport = port.getHost().port
+
+               for extport in [random.randrange(1025, 65536) for val in range(20)]:
+                       # Check if there is an existing mapping, if it does not exist, bingo
+                       if not (ptype, extport) in mappings:
+                               break
+                       
+                       if (ptype, extport) in mappings:
+                               existing = mappings[ptype, extport]
+                       
+                       local_ip, local_port = existing
+                       if local_ip == ip and local_port == intport:
+                               # Existing binding for this host/port/proto - replace it
+                               break
+               
+               # Triggers the creation of the mapping on the device
+               result = self._upnp.add_port_mapping(ip, intport, extport, 'pynupnp', ptype)
+               
+               # We also need the external IP, so we queue first an
+               # External IP Discovery, then we add the mapping.
+               return result.addCallback(
+                       lambda x: self._upnp.get_external_ip()).addCallback(
+                                                                       self._port_mapping_added, extport, port)
+
+       def _port_mapping_added(self, extaddr, extport, port):
+               """
+               The port mapping was added in the device, this means::
+               
+                    Internet        NAT         LAN
+                                     |
+                  > IP:extaddr       |>       IP:local ip
+                  > Port:extport     |>       Port:port
+                                     |              
+               
+               @param extaddr: The exernal ip address
+               @param extport: The external port as number
+               @param port: The internal port as a
+                       L{twisted.internet.interfaces.IListeningPort} object, that has been
+                       mapped
+               """
+               self._mapped[port] = (extaddr, extport)
+               return (extaddr, extport)
+
+class UPnPDevice:
+       """
+       Represents an UPnP device, with the associated infos, and remote methods.
+       """
+       def __init__(self, soap_proxy, info):
+               """
+               Build the device, with the given SOAP proxy, and the meta-infos.
+               
+               @param soap_proxy: an initialized L{SoapProxy} to the device
+               @param info: a dictionnary of various infos concerning the
+                       device extracted with L{UPnPXml}
+               """
+               self._soap_proxy = soap_proxy
+               self._info = info
+       
+       def get_external_ip(self):
+               """
+               Triggers an external ip discovery on the upnp device. Returns
+               a deferred called with the external ip of this host.
+               
+               @return: A deferred called with the ip address, as "x.x.x.x"
+               @rtype: L{twisted.internet.defer.Deferred}
+               """
+               result = self._soap_proxy.call('GetExternalIPAddress')
+               result.addCallback(self._on_external_ip)
+               return result
+       
+       def get_port_mappings(self):
+               """
+               Retreive the existing port mappings
+               
+               @see: L{portmapper.NATMapper.get_port_mappings}         
+               @return: A deferred called with the dictionnary as defined
+                       in the interface L{portmapper.NATMapper.get_port_mappings}
+               @rtype: L{twisted.internet.defer.Deferred}
+               """
+               return self._get_port_mapping()
+               
+       def add_port_mapping(self, local_ip, intport, extport, desc, proto, lease=0):
+               """
+               Add a port mapping in the upnp device. Returns a deferred.
+               
+               @param local_ip: the LAN ip of this host as "x.x.x.x"
+               @param intport: the internal port number
+               @param extport: the external port number
+               @param desc: the description of this mapping (string)
+               @param proto: "UDP" or "TCP"
+               @param lease: The duration of the lease in (mili)seconds(??)
+               @return: A deferred called with None when the mapping is done
+               @rtype: L{twisted.internet.defer.Deferred}
+               """
+               result = self._soap_proxy.call('AddPortMapping', NewRemoteHost="",
+                                                                                       NewExternalPort=extport,
+                                                                                       NewProtocol=proto,
+                                                                                       NewInternalPort=intport,
+                                                                                       NewInternalClient=local_ip,
+                                                                                       NewEnabled=1,
+                                                                                       NewPortMappingDescription=desc,
+                                                                                       NewLeaseDuration=lease)
+
+               return result.addCallbacks(self._on_port_mapping_added,
+                                          self._on_no_port_mapping_added)
+       
+       def remove_port_mapping(self, extport, proto):
+               """
+               Remove an existing port mapping on the device. Returns a deferred
+               
+               @param extport: the external port number associated to the mapping
+                       to be removed
+               @param proto: either "UDP" or "TCP"
+               @return: A deferred called with None when the mapping is done
+               @rtype: L{twisted.internet.defer.Deferred}
+               """
+               result = self._soap_proxy.call('DeletePortMapping', NewRemoteHost="",
+                                                                                               NewExternalPort=extport,
+                                                                                               NewProtocol=proto)
+
+               return result.addCallbacks(self._on_port_mapping_removed,
+                                          self._on_no_port_mapping_removed)
+               
+       # Private --------
+       def _on_external_ip(self, res):
+               """
+               Called when we received the external ip address from the device.
+               
+               @param res: the SOAPpy structure of the result
+               @return: the external ip string, as "x.x.x.x"
+               """
+               logging.debug("Got external ip struct: %r", res)
+               return res['NewExternalIPAddress']
+
+       def _get_port_mapping(self, mapping_id=0, mappings=None):
+               """
+               Fetch the existing mappings starting at index
+               "mapping_id" from the device.
+               
+               To retreive all the mappings call this without parameters.
+               
+               @param mapping_id: The index of the mapping to start fetching from 
+               @param mappings: the dictionnary of already fetched mappings
+               @return: A deferred called with the existing mappings when all have been
+                       retreived, see L{get_port_mappings}
+               @rtype: L{twisted.internet.defer.Deferred}
+               """
+               if mappings == None:
+                       mappings = {}
+                       
+               result = self._soap_proxy.call('GetGenericPortMappingEntry',
+                                                                                               NewPortMappingIndex=mapping_id)
+               return result.addCallbacks(
+                       lambda x: self._on_port_mapping_received(x, mapping_id+1, mappings),
+                       lambda x: self._on_no_port_mapping_received(    x, mappings))
+
+       def _on_port_mapping_received(self, response, mapping_id, mappings):
+               """
+               Called we we receive a single mapping from the device.
+               
+               @param response: a SOAPpy structure, representing the device's answer
+               @param mapping_id: The index of the next mapping in the device
+               @param mappings: the already fetched mappings, see L{get_port_mappings}
+               @return: A deferred called with the existing mappings when all have been
+                       retreived, see L{get_port_mappings}
+               @rtype: L{twisted.internet.defer.Deferred}
+               """
+               logging.debug("Got mapping struct: %r", response)
+               mappings[
+                       response['NewProtocol'], response['NewExternalPort']
+               ] = (response['NewInternalClient'], response['NewInternalPort'])
+               return self._get_port_mapping(mapping_id, mappings)
+
+       def _on_no_port_mapping_received(self, failure, mappings):
+               """
+               Called when we have no more port mappings to retreive, or an
+               error occured while retreiving them.
+               
+               Either we have a "SpecifiedArrayIndexInvalid" SOAP error, and that's ok,
+               it just means we have finished. If it returns some other error, then we
+               fail with an UPnPError.
+               
+               @param mappings: the already retreived mappings
+               @param failure: the failure
+               @return: The existing mappings as defined in L{get_port_mappings}
+               @raise UPnPError: When we got any other error
+                       than "SpecifiedArrayIndexInvalid"
+               """
+               logging.debug("_on_no_port_mapping_received: %s", failure)
+               err = failure.value
+               try:
+                       message = err.args[0]["UPnPError"]["errorDescription"]
+                       if "SpecifiedArrayIndexInvalid" == message:
+                               return mappings
+                       else:
+                               raise UPnPError("GetGenericPortMappingEntry got %s"%(message))
+               except:
+                       raise UPnPError("GetGenericPortMappingEntry got %s"%(err.args[0]))
+               
+
+       def _on_port_mapping_added(self, response):
+               """
+               The port mapping was successfully added, return None to the deferred.
+               """
+               return None
+               
+       def _on_no_port_mapping_added(self, failure):
+               """
+               Called when the port mapping could not be added. Immediately
+               raise an UPnPError, with the SOAPpy structure inside.
+               
+               @raise UPnPError: When the port mapping could not be added
+               """
+               raise UPnPError(failure.value.args[0])
+
+       def _on_port_mapping_removed(self, response):
+               """
+               The port mapping was successfully removed, return None to the deferred.
+               """
+               return None
+
+       def _on_no_port_mapping_removed(self, failure):
+               """
+               Called when the port mapping could not be removed. Immediately
+               raise an UPnPError, with the SOAPpy structure inside.
+               
+               @raise UPnPError: When the port mapping could not be deleted
+               """
+               raise UPnPError(failure.value.args[0])
+
+# UPNP multicast address, port and request string
+_UPNP_MCAST = '239.255.255.250'
+_UPNP_PORT = 1900
+_UPNP_SEARCH_REQUEST = """M-SEARCH * HTTP/1.1\r
+Host:%s:%s\r
+ST:urn:schemas-upnp-org:device:InternetGatewayDevice:1\r
+Man:"ssdp:discover"\r
+MX:3\r
+\r
+""" % (_UPNP_MCAST, _UPNP_PORT)
+                               
+class UPnPProtocol(DatagramProtocol, object):
+       """
+       The UPnP Device discovery udp multicast twisted protocol.
+       """
+       
+       def __init__(self, *args, **kwargs):
+               """
+               Init the protocol, no parameters needed.
+               """
+               super(UPnPProtocol, self).__init__(*args, **kwargs)
+               
+               # Url to use to talk to upnp device
+               self._control_url = None
+               self._device = None
+                               
+               #Device discovery deferred
+               self._discovery = None
+               self._discovery_timeout = None
+
+       # Public methods
+       def search_device(self):
+               """
+               Triggers a UPnP device discovery.
+               
+               The returned deferred will be called with the L{UPnPDevice} that has
+               been found in the LAN.
+               
+               @return: A deferred called with the detected L{UPnPDevice} instance.
+               @rtype: L{twisted.internet.defer.Deferred}
+               """
+               self._discovery = defer.Deferred()
+
+               attempt = 0
+               mcast = None
+               while True:
+                       try:
+                               mcast = reactor.listenMulticast(1900+attempt, self)
+                               break
+                       except CannotListenError:
+                               attempt = random.randint(0, 500)
+               
+               # joined multicast group, starting upnp search
+               mcast.joinGroup('239.255.255.250', socket.INADDR_ANY)
+
+               self.transport.write(_UPNP_SEARCH_REQUEST, (_UPNP_MCAST, _UPNP_PORT))
+               self.transport.write(_UPNP_SEARCH_REQUEST, (_UPNP_MCAST, _UPNP_PORT))
+               self.transport.write(_UPNP_SEARCH_REQUEST, (_UPNP_MCAST, _UPNP_PORT))
+
+               self._discovery_timeout = reactor.callLater(
+                                                                                               6, self._on_discovery_timeout)
+
+               return self._discovery
+               
+       #Private methods
+       def datagramReceived(self, dgram, address):
+               """
+               This is private, handle the multicast answer from the upnp device.
+               """
+               logging.debug("Got UPNP multicast search answer:\n%s", dgram)
+               
+               #This is an HTTP response
+               response, message = dgram.split('\r\n', 1)
+
+               # Prepare status line
+               version, status, textstatus = response.split(None, 2)
+               
+               if not version.startswith('HTTP') or self._control_url != None:
+                       return
+               if status != "200":
+                       return
+                       
+               # We had a timeout pending, cancel it
+               if self._discovery_timeout != None:
+                       self._discovery_timeout.cancel()
+                       self._discovery_timeout = None
+
+               # Launch the info fetching
+               def parse_discovery_response(message):
+                       """Separate headers and body from the received http answer."""
+                       hdict = {}
+                       body = ''
+                       remaining = message
+                       while remaining:
+                               line, remaining = remaining.split('\r\n', 1)
+                               line = line.strip()
+                               if not line:
+                                       body = remaining
+                                       break
+                               key, val = line.split(':', 1)
+                               key = key.lower()
+                               hdict.setdefault(key, []).append(val.strip())
+                       return hdict, body
+                       
+               headers, body = parse_discovery_response(message)
+               
+               if not 'location' in headers:
+                       self._on_discovery_failed(
+                               UPnPError(
+                                       "No location header in response to M-SEARCH!: %r"%headers))
+                       return
+                       
+               loc = headers['location'][0]
+               result = client.getPage(url=loc)
+               result.addCallback(self._on_gateway_response, loc).addErrback(
+                                                                                                       self._on_discovery_failed)
+
+       def _on_gateway_response(self, body, loc):
+               """
+               Called with the UPnP device XML description fetched via HTTP.
+               
+               If the device has suitable services for ip discovery and port mappings, 
+               the callback returned in L{search_device} is called with
+               the discovered L{UPnPDevice}.
+               
+               @raise UPnPError: When no suitable service has been
+                       found in the description, or another error occurs.
+               @param body: The xml description of the device.
+               @param loc: the url used to retreive the xml description
+               """
+               if self._control_url != None:
+                       return
+
+               # Parse answer
+               upnpinfo = UPnPXml(body)
+               
+               # Check if we have a base url, if not consider location as base url
+               urlbase = upnpinfo.urlbase
+               if urlbase == None:
+                       urlbase = loc
+               
+               # Check the control url, if None, then the device cannot do what we want
+               controlurl = upnpinfo.controlurl
+               if controlurl == None:
+                       self._on_discovery_failed(
+                                               UPnPError("upnp response showed no WANConnections"))
+                       return
+
+               self._control_url = urlparse.urljoin(urlbase, controlurl)
+
+               soap_proxy = SoapProxy(self._control_url, upnpinfo.wanservice)
+               if self._discovery != None:
+                       self._device = UPnPDevice(soap_proxy, upnpinfo.deviceinfos)
+                       self._discovery.callback(self._device)
+                       self._discovery = None
+
+       def _on_discovery_failed(self, err):
+               """
+               Called when the UPnP Device discovery has failed.
+               
+               The callback returned in L{search_device} is called with
+               an error, corresponding to the cause of the failure.
+               """
+               self._control_url = None
+               if self._discovery != None:
+                       self._discovery.errback(err)
+                       self._discovery = None
+
+       def _on_discovery_timeout(self):
+               """
+               Called when the UPnP Device discovery has timed out.
+               
+               Calls L{_on_discovery_failed}.
+               """
+               self._discovery_timeout = None
+               self._on_discovery_failed(UPnPError())
diff --git a/nattraverso/pynupnp/upnpxml.py b/nattraverso/pynupnp/upnpxml.py
new file mode 100644 (file)
index 0000000..d49df63
--- /dev/null
@@ -0,0 +1,89 @@
+"""
+This module parse an UPnP device's XML definition in an Object.
+
+@author: Raphael Slinckx
+@copyright: Copyright 2005
+@license: LGPL
+@contact: U{raphael@slinckx.net<mailto:raphael@slinckx.net>}
+@version: 0.1.0
+"""
+
+__revision__ = "$id"
+
+from xml.dom import minidom
+import logging
+
+# Allowed UPnP services to use when mapping ports/external addresses
+WANSERVICES = ['urn:schemas-upnp-org:service:WANIPConnection:1',
+                               'urn:schemas-upnp-org:service:WANPPPConnection:1']
+
+class UPnPXml:
+       """
+       This objects parses the XML definition, and stores the useful
+       results in attributes.
+
+       The device infos dictionnary may contain the following keys:
+               - friendlyname: A friendly name to call the device.
+               - manufacturer: A manufacturer name for the device.
+
+       Here are the different attributes:
+               - deviceinfos: A dictionnary of device infos as defined above.
+               - controlurl: The control url, this is the url to use when sending SOAP
+                       requests to the device, relative to the base url.
+               - wanservice: The WAN service to be used, one of the L{WANSERVICES}
+               - urlbase: The base url to use when talking in SOAP to the device.
+
+       The full url to use is obtained by urljoin(urlbase, controlurl)
+       """
+       
+       def __init__(self, xml):
+               """
+               Parse the given XML string for UPnP infos. This creates the attributes
+               when they are found, or None if no value was found.
+               
+               @param xml: a xml string to parse
+               """
+               logging.debug("Got UPNP Xml description:\n%s", xml)
+               doc = minidom.parseString(xml)
+               
+               # Fetch various device info
+               self.deviceinfos = {}
+               try:
+                       attributes = {
+                               'friendlyname':'friendlyName',
+                               'manufacturer' : 'manufacturer'
+                       }
+                       device = doc.getElementsByTagName('device')[0]
+                       for name, tag in attributes.iteritems():
+                               try:
+                                       self.deviceinfos[name] = device.getElementsByTagName(
+                                               tag)[0].firstChild.datas.encode('utf-8')
+                               except:
+                                       pass
+               except:
+                       pass
+               
+               # Fetch device control url
+               self.controlurl = None
+               self.wanservice = None
+               
+               for service in doc.getElementsByTagName('service'):
+                       try:
+                               stype = service.getElementsByTagName(
+                                       'serviceType')[0].firstChild.data.encode('utf-8')
+                               if stype in WANSERVICES:
+                                       self.controlurl = service.getElementsByTagName(
+                                               'controlURL')[0].firstChild.data.encode('utf-8')
+                                       self.wanservice = stype
+                                       break
+                       except:
+                               pass
+               
+               # Find base url
+               self.urlbase = None
+               try:
+                       self.urlbase = doc.getElementsByTagName(
+                               'URLBase')[0].firstChild.data.encode('utf-8')
+               except:
+                       pass
+               
diff --git a/nattraverso/utils.py b/nattraverso/utils.py
new file mode 100644 (file)
index 0000000..b441496
--- /dev/null
@@ -0,0 +1,56 @@
+"""
+Various utility functions used in the nattraverso package.
+
+@author: Raphael Slinckx
+@copyright: Copyright 2005
+@license: LGPL
+@contact: U{raphael@slinckx.net<mailto:raphael@slinckx.net>}
+@version: 0.1.0
+"""
+__revision__ = "$id"
+
+def is_rfc1918_ip(ip):
+       """
+       Checks if the given ip address is a rfc1918 one.
+       
+       @param ip: The ip address to test
+       @type ip: a string "x.x.x.x"
+       @return: True if it's a LAN address, False otherwise
+       """
+       if isinstance(ip, basestring):
+               ip = _ip_to_number(ip)
+
+       for net, mask in _nets:
+               if ip&mask == net:
+                       return True
+
+       return False
+
+def is_bogus_ip(ip):
+       """
+       Checks if the given ip address is bogus, i.e. 0.0.0.0 or 127.0.0.1.
+       
+       @param ip: The ip address to test
+       @type ip: a string "x.x.x.x"
+       @return: True if it's bogus, False otherwise
+       """
+       return ip.startswith('0.') or ip.startswith('127.')
+
+def _ip_to_number(ipstr):
+       """
+       Translate a string ip address to a packed number.
+       
+       @param ipstr: the ip address to transform
+       @type ipstr: a string "x.x.x.x"
+       @return: an int32 number representing the ip address
+       """
+       net = [ int(digit) for digit in ipstr.split('.') ] + [ 0, 0, 0 ]
+       net = net[:4]
+       return  ((((((0L+net[0])<<8) + net[1])<<8) + net[2])<<8) +net[3]
+
+# List of rfc1918 net/mask
+_rfc1918_networks = [('127', 8), ('192.168', 16), ('10', 8), ('172.16', 12)]
+# Machine readable form of the above
+_nets = [(_ip_to_number(net), (2L**32 -1)^(2L**(32-mask)-1))
+                       for net, mask in _rfc1918_networks]
+                       
index e503411..2fa4ad3 100644 (file)
@@ -16,6 +16,7 @@ import json
 from twisted.internet import defer, reactor
 from twisted.web import server, resource
 from twisted.python import log
+from nattraverso import portmapper, ipdiscover
 
 import bitcoin.p2p, bitcoin.getwork, bitcoin.data
 from util import db, expiring_dict, jsonrpc, variable, deferral, math
@@ -287,6 +288,22 @@ def main(args):
         print '    ...success!'
         print
         
+        @defer.inlineCallbacks
+        def upnp_thread():
+            while True:
+                try:
+                    is_lan, lan_ip = yield ipdiscover.get_local_ip()
+                    if not is_lan:
+                        continue
+                    pm = yield portmapper.get_port_mapper()
+                    yield pm._upnp.add_port_mapping(lan_ip, args.net.P2P_PORT, args.net.P2P_PORT, 'p2pool', 'TCP')
+                except:
+                    log.err()
+                yield deferral.sleep(random.expovariate(1/120))
+        
+        if args.upnp:
+            upnp_thread()
+         
         # start listening for workers with a JSON-RPC server
         
         print 'Listening for workers on port %i...' % (args.worker_port,)
@@ -554,6 +571,9 @@ def run():
     parser.add_argument('-l', '--low-bandwidth',
         help='trade lower bandwidth usage for higher latency (reduced efficiency)',
         action='store_true', default=False, dest='low_bandwidth')
+    parser.add_argument('--disable-upnp',
+        help='''don't attempt to forward port 9333 (19333 for testnet) from the WAN to this computer using UPnP''',
+        action='store_false', default=True, dest='upnp')
     
     worker_group = parser.add_argument_group('worker interface')
     worker_group.add_argument('-w', '--worker-port', metavar='PORT',