2 This module is the heart of the upnp support. Device discover, ip discovery
3 and port mappings are implemented here.
5 @author: Raphael Slinckx
6 @author: Anthony Baxter
7 @copyright: Copyright 2005
9 @contact: U{raphael@slinckx.net<mailto:raphael@slinckx.net>}
14 import socket, random, urlparse, logging
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
21 from nattraverso.pynupnp.soap import SoapProxy
22 from nattraverso.pynupnp.upnpxml import UPnPXml
23 from nattraverso import ipdiscover, portmapper
25 class UPnPError(Exception):
27 A generic UPnP error, with a descriptive message as content.
31 class UPnPMapper(portmapper.NATMapper):
33 This is the UPnP port mapper implementing the
34 L{NATMapper<portmapper.NATMapper>} interface.
36 @see: L{NATMapper<portmapper.NATMapper>}
39 def __init__(self, upnp):
41 Creates the mapper, with the given L{UPnPDevice} instance.
43 @param upnp: L{UPnPDevice} instance
52 self._check_valid_port(port)
54 #Port is already mapped
55 if port in self._mapped:
56 return defer.succeed(self._mapped[port])
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)
67 # If the mapping exists, everything's ok
68 if port in self._mapped:
69 return self._mapped[port]
71 raise ValueError('Port %r is not currently mapped'%(port))
73 def unmap(self, port):
77 if port in self._mapped:
78 existing = self._mapped[port]
80 #Pending mapping, queue an unmap,return existing deferred
81 if type(existing) is not tuple:
82 existing.addCallback(lambda x: self.unmap(port))
85 #Remove our local mapping
86 del self._mapped[port]
88 #Ask the UPnP to remove the mapping
89 extaddr, extport = existing
90 return self._upnp.remove_port_mapping(extport, port.getHost().type)
92 raise ValueError('Port %r is not currently mapped'%(port))
94 def get_port_mappings(self):
98 return self._upnp.get_port_mappings()
100 def _map_got_local_ip(self, ip_result, port):
102 We got the local ip address, retreive the existing port mappings
105 @param ip_result: result of L{ipdiscover.get_local_ip}
106 @param port: a L{twisted.internet.interfaces.IListeningPort} we
109 local, ip = ip_result
110 return self._upnp.get_port_mappings().addCallback(
111 self._map_got_port_mappings, ip, port)
113 def _map_got_port_mappings(self, mappings, ip, port):
115 We got all the existing mappings in the device, find an unused one
116 and assign it for the requested port.
118 @param ip: The local ip of this host "x.x.x.x"
119 @param port: a L{twisted.internet.interfaces.IListeningPort} we
121 @param mappings: result of L{UPnPDevice.get_port_mappings}
124 #Get the requested mapping's info
125 ptype = port.getHost().type
126 intport = port.getHost().port
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:
133 if (ptype, extport) in mappings:
134 existing = mappings[ptype, extport]
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
141 # Triggers the creation of the mapping on the device
142 result = self._upnp.add_port_mapping(ip, intport, extport, 'pynupnp', ptype)
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)
150 def _port_mapping_added(self, extaddr, extport, port):
152 The port mapping was added in the device, this means::
156 > IP:extaddr |> IP:local ip
157 > Port:extport |> Port:port
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
166 self._mapped[port] = (extaddr, extport)
167 return (extaddr, extport)
171 Represents an UPnP device, with the associated infos, and remote methods.
173 def __init__(self, soap_proxy, info):
175 Build the device, with the given SOAP proxy, and the meta-infos.
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}
181 self._soap_proxy = soap_proxy
184 def get_external_ip(self):
186 Triggers an external ip discovery on the upnp device. Returns
187 a deferred called with the external ip of this host.
189 @return: A deferred called with the ip address, as "x.x.x.x"
190 @rtype: L{twisted.internet.defer.Deferred}
192 result = self._soap_proxy.call('GetExternalIPAddress')
193 result.addCallback(self._on_external_ip)
196 def get_port_mappings(self):
198 Retreive the existing port mappings
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}
205 return self._get_port_mapping()
207 def add_port_mapping(self, local_ip, intport, extport, desc, proto, lease=0):
209 Add a port mapping in the upnp device. Returns a deferred.
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}
220 result = self._soap_proxy.call('AddPortMapping', NewRemoteHost="",
221 NewExternalPort=extport,
223 NewInternalPort=intport,
224 NewInternalClient=local_ip,
226 NewPortMappingDescription=desc,
227 NewLeaseDuration=lease)
229 return result.addCallbacks(self._on_port_mapping_added,
230 self._on_no_port_mapping_added)
232 def remove_port_mapping(self, extport, proto):
234 Remove an existing port mapping on the device. Returns a deferred
236 @param extport: the external port number associated to the mapping
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}
242 result = self._soap_proxy.call('DeletePortMapping', NewRemoteHost="",
243 NewExternalPort=extport,
246 return result.addCallbacks(self._on_port_mapping_removed,
247 self._on_no_port_mapping_removed)
250 def _on_external_ip(self, res):
252 Called when we received the external ip address from the device.
254 @param res: the SOAPpy structure of the result
255 @return: the external ip string, as "x.x.x.x"
257 logging.debug("Got external ip struct: %r", res)
258 return res['NewExternalIPAddress']
260 def _get_port_mapping(self, mapping_id=0, mappings=None):
262 Fetch the existing mappings starting at index
263 "mapping_id" from the device.
265 To retreive all the mappings call this without parameters.
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}
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))
282 def _on_port_mapping_received(self, response, mapping_id, mappings):
284 Called we we receive a single mapping from the device.
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}
293 logging.debug("Got mapping struct: %r", response)
295 response['NewProtocol'], response['NewExternalPort']
296 ] = (response['NewInternalClient'], response['NewInternalPort'])
297 return self._get_port_mapping(mapping_id, mappings)
299 def _on_no_port_mapping_received(self, failure, mappings):
301 Called when we have no more port mappings to retreive, or an
302 error occured while retreiving them.
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.
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"
314 logging.debug("_on_no_port_mapping_received: %s", failure)
316 message = err.args[0]["UPnPError"]["errorDescription"]
317 if "SpecifiedArrayIndexInvalid" == message:
323 def _on_port_mapping_added(self, response):
325 The port mapping was successfully added, return None to the deferred.
329 def _on_no_port_mapping_added(self, failure):
331 Called when the port mapping could not be added. Immediately
332 raise an UPnPError, with the SOAPpy structure inside.
334 @raise UPnPError: When the port mapping could not be added
338 def _on_port_mapping_removed(self, response):
340 The port mapping was successfully removed, return None to the deferred.
344 def _on_no_port_mapping_removed(self, failure):
346 Called when the port mapping could not be removed. Immediately
347 raise an UPnPError, with the SOAPpy structure inside.
349 @raise UPnPError: When the port mapping could not be deleted
353 # UPNP multicast address, port and request string
354 _UPNP_MCAST = '239.255.255.250'
356 _UPNP_SEARCH_REQUEST = """M-SEARCH * HTTP/1.1\r
358 ST:urn:schemas-upnp-org:device:InternetGatewayDevice:1\r
359 Man:"ssdp:discover"\r
362 """ % (_UPNP_MCAST, _UPNP_PORT)
364 class UPnPProtocol(DatagramProtocol, object):
366 The UPnP Device discovery udp multicast twisted protocol.
369 def __init__(self, *args, **kwargs):
371 Init the protocol, no parameters needed.
373 super(UPnPProtocol, self).__init__(*args, **kwargs)
375 #Device discovery deferred
376 self._discovery = None
377 self._discovery_timeout = None
382 def search_device(self):
384 Triggers a UPnP device discovery.
386 The returned deferred will be called with the L{UPnPDevice} that has
387 been found in the LAN.
389 @return: A deferred called with the detected L{UPnPDevice} instance.
390 @rtype: L{twisted.internet.defer.Deferred}
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)
401 self.mcast = reactor.listenMulticast(1900+attempt, self)
403 except CannotListenError:
404 attempt = random.randint(0, 500)
406 # joined multicast group, starting upnp search
407 self.mcast.joinGroup('239.255.255.250', socket.INADDR_ANY)
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))
413 return self._discovery
416 def datagramReceived(self, dgram, address):
420 This is private, handle the multicast answer from the upnp device.
422 logging.debug("Got UPNP multicast search answer:\n%s", dgram)
424 #This is an HTTP response
425 response, message = dgram.split('\r\n', 1)
427 # Prepare status line
428 version, status, textstatus = response.split(None, 2)
430 if not version.startswith('HTTP'):
435 # Launch the info fetching
436 def parse_discovery_response(message):
437 """Separate headers and body from the received http answer."""
442 line, remaining = remaining.split('\r\n', 1)
447 key, val = line.split(':', 1)
449 hdict.setdefault(key, []).append(val.strip())
452 headers, body = parse_discovery_response(message)
454 if not 'location' in headers:
455 self._on_discovery_failed(
457 "No location header in response to M-SEARCH!: %r"%headers))
460 loc = headers['location'][0]
461 result = client.getPage(url=loc)
462 result.addCallback(self._on_gateway_response, loc).addErrback(self._on_discovery_failed)
464 def _on_gateway_response(self, body, loc):
468 Called with the UPnP device XML description fetched via HTTP.
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}.
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
481 upnpinfo = UPnPXml(body)
483 # Check if we have a base url, if not consider location as base url
484 urlbase = upnpinfo.urlbase
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"))
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))
498 def _on_discovery_succeeded(self, res):
502 self.mcast.stopListening()
503 self._discovery_timeout.cancel()
504 self._discovery.callback(res)
506 def _on_discovery_failed(self, err):
510 self.mcast.stopListening()
511 self._discovery_timeout.cancel()
512 self._discovery.errback(err)
514 def _on_discovery_timeout(self):
518 self.mcast.stopListening()
519 self._discovery.errback(failure.Failure(defer.TimeoutError()))
521 def search_upnp_device ():
523 Check the network for an UPnP device. Returns a deferred
524 with the L{UPnPDevice} instance as result, if found.
526 @return: A deferred called with the L{UPnPDevice} instance
527 @rtype: L{twisted.internet.defer.Deferred}
529 return defer.maybeDeferred(UPnPProtocol().search_device)