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