upnp port forwarding
[p2pool.git] / nattraverso / pynupnp / upnp.py
1 """
2 This module is the heart of the upnp support. Device discover, ip discovery
3 and port mappings are implemented here.
4
5 @author: Raphael Slinckx
6 @author: Anthony Baxter
7 @copyright: Copyright 2005
8 @license: LGPL
9 @contact: U{raphael@slinckx.net<mailto:raphael@slinckx.net>}
10 @version: 0.1.0
11 """
12 __revision__ = "$id"
13
14 import socket, random, urlparse, logging
15
16 from twisted.internet import reactor, defer
17 from twisted.web import client
18 from twisted.internet.protocol import DatagramProtocol
19 from twisted.internet.error import CannotListenError
20
21 from nattraverso.pynupnp.soap import SoapProxy
22 from nattraverso.pynupnp.upnpxml import UPnPXml
23 from nattraverso import ipdiscover, portmapper
24
25 class UPnPError(Exception):
26         """
27         A generic UPnP error, with a descriptive message as content.
28         """
29         pass
30
31 def search_upnp_device ():
32         """
33         Check the network for an UPnP device. Returns a deferred
34         with the L{UPnPDevice} instance as result, if found.
35         
36         @return: A deferred called with the L{UPnPDevice} instance
37         @rtype: L{twisted.internet.defer.Deferred}
38         """
39         try:
40                 return UPnPProtocol().search_device()
41         except Exception, msg:
42                 return defer.fail(UPnPError(msg))
43
44 class UPnPMapper(portmapper.NATMapper):
45         """
46         This is the UPnP port mapper implementing the
47         L{NATMapper<portmapper.NATMapper>} interface.
48         
49         @see: L{NATMapper<portmapper.NATMapper>}
50         """
51         
52         def __init__(self, upnp):
53                 """
54                 Creates the mapper, with the given L{UPnPDevice} instance.
55                 
56                 @param upnp: L{UPnPDevice} instance
57                 """
58                 self._mapped = {}
59                 self._upnp = upnp
60                 
61         def map(self, port):
62                 """
63                 See interface
64                 """
65                 self._check_valid_port(port)
66
67                 #Port is already mapped
68                 if port in self._mapped:
69                         return defer.succeed(self._mapped[port])
70                 
71                 #Trigger a new mapping creation, first fetch local ip.
72                 result = ipdiscover.get_local_ip()
73                 self._mapped[port] = result
74                 return result.addCallback(self._map_got_local_ip, port)
75         
76         def info(self, port):
77                 """
78                 See interface
79                 """
80                 # If the mapping exists, everything's ok
81                 if port in self._mapped:
82                         return self._mapped[port]
83                 else:
84                         raise ValueError('Port %r is not currently mapped'%(port))
85
86         def unmap(self, port):
87                 """
88                 See interface
89                 """
90                 if port in self._mapped:
91                         existing = self._mapped[port]
92                         
93                         #Pending mapping, queue an unmap,return existing deferred
94                         if type(existing) is not tuple:
95                                 existing.addCallback(lambda x: self.unmap(port))
96                                 return existing
97                         
98                         #Remove our local mapping
99                         del self._mapped[port]
100                         
101                         #Ask the UPnP to remove the mapping
102                         extaddr, extport = existing
103                         return self._upnp.remove_port_mapping(extport, port.getHost().type)
104                 else:
105                         raise ValueError('Port %r is not currently mapped'%(port))
106         
107         def get_port_mappings(self):
108                 """
109                 See interface
110                 """
111                 return self._upnp.get_port_mappings()
112                 
113         def _map_got_local_ip(self, ip_result, port):
114                 """
115                 We got the local ip address, retreive the existing port mappings
116                 in the device.
117                 
118                 @param ip_result: result of L{ipdiscover.get_local_ip}
119                 @param port: a L{twisted.internet.interfaces.IListeningPort} we
120                         want to map
121                 """
122                 local, ip = ip_result
123                 return self._upnp.get_port_mappings().addCallback(
124                                                                                 self._map_got_port_mappings, ip, port)
125                 
126         def _map_got_port_mappings(self, mappings, ip, port):
127                 """
128                 We got all the existing mappings in the device, find an unused one
129                 and assign it for the requested port.
130                 
131                 @param ip: The local ip of this host "x.x.x.x"
132                 @param port: a L{twisted.internet.interfaces.IListeningPort} we
133                         want to map
134                 @param mappings: result of L{UPnPDevice.get_port_mappings}
135                 """
136
137                 #Get the requested mapping's info
138                 ptype = port.getHost().type
139                 intport = port.getHost().port
140
141                 for extport in [random.randrange(1025, 65536) for val in range(20)]:
142                         # Check if there is an existing mapping, if it does not exist, bingo
143                         if not (ptype, extport) in mappings:
144                                 break
145                         
146                         if (ptype, extport) in mappings:
147                                 existing = mappings[ptype, extport]
148                         
149                         local_ip, local_port = existing
150                         if local_ip == ip and local_port == intport:
151                                 # Existing binding for this host/port/proto - replace it
152                                 break
153                 
154                 # Triggers the creation of the mapping on the device
155                 result = self._upnp.add_port_mapping(ip, intport, extport, 'pynupnp', ptype)
156                 
157                 # We also need the external IP, so we queue first an
158                 # External IP Discovery, then we add the mapping.
159                 return result.addCallback(
160                         lambda x: self._upnp.get_external_ip()).addCallback(
161                                                                         self._port_mapping_added, extport, port)
162
163         def _port_mapping_added(self, extaddr, extport, port):
164                 """
165                 The port mapping was added in the device, this means::
166                 
167                      Internet        NAT         LAN
168                                       |
169                    > IP:extaddr       |>       IP:local ip
170                    > Port:extport     |>       Port:port
171                                       |              
172                 
173                 @param extaddr: The exernal ip address
174                 @param extport: The external port as number
175                 @param port: The internal port as a
176                         L{twisted.internet.interfaces.IListeningPort} object, that has been
177                         mapped
178                 """
179                 self._mapped[port] = (extaddr, extport)
180                 return (extaddr, extport)
181
182 class UPnPDevice:
183         """
184         Represents an UPnP device, with the associated infos, and remote methods.
185         """
186         def __init__(self, soap_proxy, info):
187                 """
188                 Build the device, with the given SOAP proxy, and the meta-infos.
189                 
190                 @param soap_proxy: an initialized L{SoapProxy} to the device
191                 @param info: a dictionnary of various infos concerning the
192                         device extracted with L{UPnPXml}
193                 """
194                 self._soap_proxy = soap_proxy
195                 self._info = info
196         
197         def get_external_ip(self):
198                 """
199                 Triggers an external ip discovery on the upnp device. Returns
200                 a deferred called with the external ip of this host.
201                 
202                 @return: A deferred called with the ip address, as "x.x.x.x"
203                 @rtype: L{twisted.internet.defer.Deferred}
204                 """
205                 result = self._soap_proxy.call('GetExternalIPAddress')
206                 result.addCallback(self._on_external_ip)
207                 return result
208         
209         def get_port_mappings(self):
210                 """
211                 Retreive the existing port mappings
212                 
213                 @see: L{portmapper.NATMapper.get_port_mappings}         
214                 @return: A deferred called with the dictionnary as defined
215                         in the interface L{portmapper.NATMapper.get_port_mappings}
216                 @rtype: L{twisted.internet.defer.Deferred}
217                 """
218                 return self._get_port_mapping()
219                 
220         def add_port_mapping(self, local_ip, intport, extport, desc, proto, lease=0):
221                 """
222                 Add a port mapping in the upnp device. Returns a deferred.
223                 
224                 @param local_ip: the LAN ip of this host as "x.x.x.x"
225                 @param intport: the internal port number
226                 @param extport: the external port number
227                 @param desc: the description of this mapping (string)
228                 @param proto: "UDP" or "TCP"
229                 @param lease: The duration of the lease in (mili)seconds(??)
230                 @return: A deferred called with None when the mapping is done
231                 @rtype: L{twisted.internet.defer.Deferred}
232                 """
233                 result = self._soap_proxy.call('AddPortMapping', NewRemoteHost="",
234                                                                                         NewExternalPort=extport,
235                                                                                         NewProtocol=proto,
236                                                                                         NewInternalPort=intport,
237                                                                                         NewInternalClient=local_ip,
238                                                                                         NewEnabled=1,
239                                                                                         NewPortMappingDescription=desc,
240                                                                                         NewLeaseDuration=lease)
241
242                 return result.addCallbacks(self._on_port_mapping_added,
243                                            self._on_no_port_mapping_added)
244         
245         def remove_port_mapping(self, extport, proto):
246                 """
247                 Remove an existing port mapping on the device. Returns a deferred
248                 
249                 @param extport: the external port number associated to the mapping
250                         to be removed
251                 @param proto: either "UDP" or "TCP"
252                 @return: A deferred called with None when the mapping is done
253                 @rtype: L{twisted.internet.defer.Deferred}
254                 """
255                 result = self._soap_proxy.call('DeletePortMapping', NewRemoteHost="",
256                                                                                                 NewExternalPort=extport,
257                                                                                                 NewProtocol=proto)
258
259                 return result.addCallbacks(self._on_port_mapping_removed,
260                                            self._on_no_port_mapping_removed)
261                 
262         # Private --------
263         def _on_external_ip(self, res):
264                 """
265                 Called when we received the external ip address from the device.
266                 
267                 @param res: the SOAPpy structure of the result
268                 @return: the external ip string, as "x.x.x.x"
269                 """
270                 logging.debug("Got external ip struct: %r", res)
271                 return res['NewExternalIPAddress']
272
273         def _get_port_mapping(self, mapping_id=0, mappings=None):
274                 """
275                 Fetch the existing mappings starting at index
276                 "mapping_id" from the device.
277                 
278                 To retreive all the mappings call this without parameters.
279                 
280                 @param mapping_id: The index of the mapping to start fetching from 
281                 @param mappings: the dictionnary of already fetched mappings
282                 @return: A deferred called with the existing mappings when all have been
283                         retreived, see L{get_port_mappings}
284                 @rtype: L{twisted.internet.defer.Deferred}
285                 """
286                 if mappings == None:
287                         mappings = {}
288                         
289                 result = self._soap_proxy.call('GetGenericPortMappingEntry',
290                                                                                                 NewPortMappingIndex=mapping_id)
291                 return result.addCallbacks(
292                         lambda x: self._on_port_mapping_received(x, mapping_id+1, mappings),
293                         lambda x: self._on_no_port_mapping_received(    x, mappings))
294
295         def _on_port_mapping_received(self, response, mapping_id, mappings):
296                 """
297                 Called we we receive a single mapping from the device.
298                 
299                 @param response: a SOAPpy structure, representing the device's answer
300                 @param mapping_id: The index of the next mapping in the device
301                 @param mappings: the already fetched mappings, see L{get_port_mappings}
302                 @return: A deferred called with the existing mappings when all have been
303                         retreived, see L{get_port_mappings}
304                 @rtype: L{twisted.internet.defer.Deferred}
305                 """
306                 logging.debug("Got mapping struct: %r", response)
307                 mappings[
308                         response['NewProtocol'], response['NewExternalPort']
309                 ] = (response['NewInternalClient'], response['NewInternalPort'])
310                 return self._get_port_mapping(mapping_id, mappings)
311
312         def _on_no_port_mapping_received(self, failure, mappings):
313                 """
314                 Called when we have no more port mappings to retreive, or an
315                 error occured while retreiving them.
316                 
317                 Either we have a "SpecifiedArrayIndexInvalid" SOAP error, and that's ok,
318                 it just means we have finished. If it returns some other error, then we
319                 fail with an UPnPError.
320                 
321                 @param mappings: the already retreived mappings
322                 @param failure: the failure
323                 @return: The existing mappings as defined in L{get_port_mappings}
324                 @raise UPnPError: When we got any other error
325                         than "SpecifiedArrayIndexInvalid"
326                 """
327                 logging.debug("_on_no_port_mapping_received: %s", failure)
328                 err = failure.value
329                 try:
330                         message = err.args[0]["UPnPError"]["errorDescription"]
331                         if "SpecifiedArrayIndexInvalid" == message:
332                                 return mappings
333                         else:
334                                 raise UPnPError("GetGenericPortMappingEntry got %s"%(message))
335                 except:
336                         raise UPnPError("GetGenericPortMappingEntry got %s"%(err.args[0]))
337                 
338
339         def _on_port_mapping_added(self, response):
340                 """
341                 The port mapping was successfully added, return None to the deferred.
342                 """
343                 return None
344                 
345         def _on_no_port_mapping_added(self, failure):
346                 """
347                 Called when the port mapping could not be added. Immediately
348                 raise an UPnPError, with the SOAPpy structure inside.
349                 
350                 @raise UPnPError: When the port mapping could not be added
351                 """
352                 raise UPnPError(failure.value.args[0])
353
354         def _on_port_mapping_removed(self, response):
355                 """
356                 The port mapping was successfully removed, return None to the deferred.
357                 """
358                 return None
359
360         def _on_no_port_mapping_removed(self, failure):
361                 """
362                 Called when the port mapping could not be removed. Immediately
363                 raise an UPnPError, with the SOAPpy structure inside.
364                 
365                 @raise UPnPError: When the port mapping could not be deleted
366                 """
367                 raise UPnPError(failure.value.args[0])
368
369 # UPNP multicast address, port and request string
370 _UPNP_MCAST = '239.255.255.250'
371 _UPNP_PORT = 1900
372 _UPNP_SEARCH_REQUEST = """M-SEARCH * HTTP/1.1\r
373 Host:%s:%s\r
374 ST:urn:schemas-upnp-org:device:InternetGatewayDevice:1\r
375 Man:"ssdp:discover"\r
376 MX:3\r
377 \r
378 """ % (_UPNP_MCAST, _UPNP_PORT)
379                                 
380 class UPnPProtocol(DatagramProtocol, object):
381         """
382         The UPnP Device discovery udp multicast twisted protocol.
383         """
384         
385         def __init__(self, *args, **kwargs):
386                 """
387                 Init the protocol, no parameters needed.
388                 """
389                 super(UPnPProtocol, self).__init__(*args, **kwargs)
390                 
391                 # Url to use to talk to upnp device
392                 self._control_url = None
393                 self._device = None
394                                 
395                 #Device discovery deferred
396                 self._discovery = None
397                 self._discovery_timeout = None
398
399         # Public methods
400         def search_device(self):
401                 """
402                 Triggers a UPnP device discovery.
403                 
404                 The returned deferred will be called with the L{UPnPDevice} that has
405                 been found in the LAN.
406                 
407                 @return: A deferred called with the detected L{UPnPDevice} instance.
408                 @rtype: L{twisted.internet.defer.Deferred}
409                 """
410                 self._discovery = defer.Deferred()
411
412                 attempt = 0
413                 mcast = None
414                 while True:
415                         try:
416                                 mcast = reactor.listenMulticast(1900+attempt, self)
417                                 break
418                         except CannotListenError:
419                                 attempt = random.randint(0, 500)
420                 
421                 # joined multicast group, starting upnp search
422                 mcast.joinGroup('239.255.255.250', socket.INADDR_ANY)
423
424                 self.transport.write(_UPNP_SEARCH_REQUEST, (_UPNP_MCAST, _UPNP_PORT))
425                 self.transport.write(_UPNP_SEARCH_REQUEST, (_UPNP_MCAST, _UPNP_PORT))
426                 self.transport.write(_UPNP_SEARCH_REQUEST, (_UPNP_MCAST, _UPNP_PORT))
427
428                 self._discovery_timeout = reactor.callLater(
429                                                                                                 6, self._on_discovery_timeout)
430
431                 return self._discovery
432                 
433         #Private methods
434         def datagramReceived(self, dgram, address):
435                 """
436                 This is private, handle the multicast answer from the upnp device.
437                 """
438                 logging.debug("Got UPNP multicast search answer:\n%s", dgram)
439                 
440                 #This is an HTTP response
441                 response, message = dgram.split('\r\n', 1)
442
443                 # Prepare status line
444                 version, status, textstatus = response.split(None, 2)
445                 
446                 if not version.startswith('HTTP') or self._control_url != None:
447                         return
448                 if status != "200":
449                         return
450                         
451                 # We had a timeout pending, cancel it
452                 if self._discovery_timeout != None:
453                         self._discovery_timeout.cancel()
454                         self._discovery_timeout = None
455
456                 # Launch the info fetching
457                 def parse_discovery_response(message):
458                         """Separate headers and body from the received http answer."""
459                         hdict = {}
460                         body = ''
461                         remaining = message
462                         while remaining:
463                                 line, remaining = remaining.split('\r\n', 1)
464                                 line = line.strip()
465                                 if not line:
466                                         body = remaining
467                                         break
468                                 key, val = line.split(':', 1)
469                                 key = key.lower()
470                                 hdict.setdefault(key, []).append(val.strip())
471                         return hdict, body
472                         
473                 headers, body = parse_discovery_response(message)
474                 
475                 if not 'location' in headers:
476                         self._on_discovery_failed(
477                                 UPnPError(
478                                         "No location header in response to M-SEARCH!: %r"%headers))
479                         return
480                         
481                 loc = headers['location'][0]
482                 result = client.getPage(url=loc)
483                 result.addCallback(self._on_gateway_response, loc).addErrback(
484                                                                                                         self._on_discovery_failed)
485
486         def _on_gateway_response(self, body, loc):
487                 """
488                 Called with the UPnP device XML description fetched via HTTP.
489                 
490                 If the device has suitable services for ip discovery and port mappings, 
491                 the callback returned in L{search_device} is called with
492                 the discovered L{UPnPDevice}.
493                 
494                 @raise UPnPError: When no suitable service has been
495                         found in the description, or another error occurs.
496                 @param body: The xml description of the device.
497                 @param loc: the url used to retreive the xml description
498                 """
499                 if self._control_url != None:
500                         return
501
502                 # Parse answer
503                 upnpinfo = UPnPXml(body)
504                 
505                 # Check if we have a base url, if not consider location as base url
506                 urlbase = upnpinfo.urlbase
507                 if urlbase == None:
508                         urlbase = loc
509                 
510                 # Check the control url, if None, then the device cannot do what we want
511                 controlurl = upnpinfo.controlurl
512                 if controlurl == None:
513                         self._on_discovery_failed(
514                                                 UPnPError("upnp response showed no WANConnections"))
515                         return
516
517                 self._control_url = urlparse.urljoin(urlbase, controlurl)
518
519                 soap_proxy = SoapProxy(self._control_url, upnpinfo.wanservice)
520                 if self._discovery != None:
521                         self._device = UPnPDevice(soap_proxy, upnpinfo.deviceinfos)
522                         self._discovery.callback(self._device)
523                         self._discovery = None
524
525         def _on_discovery_failed(self, err):
526                 """
527                 Called when the UPnP Device discovery has failed.
528                 
529                 The callback returned in L{search_device} is called with
530                 an error, corresponding to the cause of the failure.
531                 """
532                 self._control_url = None
533                 if self._discovery != None:
534                         self._discovery.errback(err)
535                         self._discovery = None
536
537         def _on_discovery_timeout(self):
538                 """
539                 Called when the UPnP Device discovery has timed out.
540                 
541                 Calls L{_on_discovery_failed}.
542                 """
543                 self._discovery_timeout = None
544                 self._on_discovery_failed(UPnPError())