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 def search_upnp_device ():
33 Check the network for an UPnP device. Returns a deferred
34 with the L{UPnPDevice} instance as result, if found.
36 @return: A deferred called with the L{UPnPDevice} instance
37 @rtype: L{twisted.internet.defer.Deferred}
40 return UPnPProtocol().search_device()
41 except Exception, msg:
42 return defer.fail(UPnPError(msg))
44 class UPnPMapper(portmapper.NATMapper):
46 This is the UPnP port mapper implementing the
47 L{NATMapper<portmapper.NATMapper>} interface.
49 @see: L{NATMapper<portmapper.NATMapper>}
52 def __init__(self, upnp):
54 Creates the mapper, with the given L{UPnPDevice} instance.
56 @param upnp: L{UPnPDevice} instance
65 self._check_valid_port(port)
67 #Port is already mapped
68 if port in self._mapped:
69 return defer.succeed(self._mapped[port])
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)
80 # If the mapping exists, everything's ok
81 if port in self._mapped:
82 return self._mapped[port]
84 raise ValueError('Port %r is not currently mapped'%(port))
86 def unmap(self, port):
90 if port in self._mapped:
91 existing = self._mapped[port]
93 #Pending mapping, queue an unmap,return existing deferred
94 if type(existing) is not tuple:
95 existing.addCallback(lambda x: self.unmap(port))
98 #Remove our local mapping
99 del self._mapped[port]
101 #Ask the UPnP to remove the mapping
102 extaddr, extport = existing
103 return self._upnp.remove_port_mapping(extport, port.getHost().type)
105 raise ValueError('Port %r is not currently mapped'%(port))
107 def get_port_mappings(self):
111 return self._upnp.get_port_mappings()
113 def _map_got_local_ip(self, ip_result, port):
115 We got the local ip address, retreive the existing port mappings
118 @param ip_result: result of L{ipdiscover.get_local_ip}
119 @param port: a L{twisted.internet.interfaces.IListeningPort} we
122 local, ip = ip_result
123 return self._upnp.get_port_mappings().addCallback(
124 self._map_got_port_mappings, ip, port)
126 def _map_got_port_mappings(self, mappings, ip, port):
128 We got all the existing mappings in the device, find an unused one
129 and assign it for the requested port.
131 @param ip: The local ip of this host "x.x.x.x"
132 @param port: a L{twisted.internet.interfaces.IListeningPort} we
134 @param mappings: result of L{UPnPDevice.get_port_mappings}
137 #Get the requested mapping's info
138 ptype = port.getHost().type
139 intport = port.getHost().port
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:
146 if (ptype, extport) in mappings:
147 existing = mappings[ptype, extport]
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
154 # Triggers the creation of the mapping on the device
155 result = self._upnp.add_port_mapping(ip, intport, extport, 'pynupnp', ptype)
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)
163 def _port_mapping_added(self, extaddr, extport, port):
165 The port mapping was added in the device, this means::
169 > IP:extaddr |> IP:local ip
170 > Port:extport |> Port:port
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
179 self._mapped[port] = (extaddr, extport)
180 return (extaddr, extport)
184 Represents an UPnP device, with the associated infos, and remote methods.
186 def __init__(self, soap_proxy, info):
188 Build the device, with the given SOAP proxy, and the meta-infos.
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}
194 self._soap_proxy = soap_proxy
197 def get_external_ip(self):
199 Triggers an external ip discovery on the upnp device. Returns
200 a deferred called with the external ip of this host.
202 @return: A deferred called with the ip address, as "x.x.x.x"
203 @rtype: L{twisted.internet.defer.Deferred}
205 result = self._soap_proxy.call('GetExternalIPAddress')
206 result.addCallback(self._on_external_ip)
209 def get_port_mappings(self):
211 Retreive the existing port mappings
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}
218 return self._get_port_mapping()
220 def add_port_mapping(self, local_ip, intport, extport, desc, proto, lease=0):
222 Add a port mapping in the upnp device. Returns a deferred.
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}
233 result = self._soap_proxy.call('AddPortMapping', NewRemoteHost="",
234 NewExternalPort=extport,
236 NewInternalPort=intport,
237 NewInternalClient=local_ip,
239 NewPortMappingDescription=desc,
240 NewLeaseDuration=lease)
242 return result.addCallbacks(self._on_port_mapping_added,
243 self._on_no_port_mapping_added)
245 def remove_port_mapping(self, extport, proto):
247 Remove an existing port mapping on the device. Returns a deferred
249 @param extport: the external port number associated to the mapping
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}
255 result = self._soap_proxy.call('DeletePortMapping', NewRemoteHost="",
256 NewExternalPort=extport,
259 return result.addCallbacks(self._on_port_mapping_removed,
260 self._on_no_port_mapping_removed)
263 def _on_external_ip(self, res):
265 Called when we received the external ip address from the device.
267 @param res: the SOAPpy structure of the result
268 @return: the external ip string, as "x.x.x.x"
270 logging.debug("Got external ip struct: %r", res)
271 return res['NewExternalIPAddress']
273 def _get_port_mapping(self, mapping_id=0, mappings=None):
275 Fetch the existing mappings starting at index
276 "mapping_id" from the device.
278 To retreive all the mappings call this without parameters.
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}
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))
295 def _on_port_mapping_received(self, response, mapping_id, mappings):
297 Called we we receive a single mapping from the device.
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}
306 logging.debug("Got mapping struct: %r", response)
308 response['NewProtocol'], response['NewExternalPort']
309 ] = (response['NewInternalClient'], response['NewInternalPort'])
310 return self._get_port_mapping(mapping_id, mappings)
312 def _on_no_port_mapping_received(self, failure, mappings):
314 Called when we have no more port mappings to retreive, or an
315 error occured while retreiving them.
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.
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"
327 logging.debug("_on_no_port_mapping_received: %s", failure)
330 message = err.args[0]["UPnPError"]["errorDescription"]
331 if "SpecifiedArrayIndexInvalid" == message:
334 raise UPnPError("GetGenericPortMappingEntry got %s"%(message))
336 raise UPnPError("GetGenericPortMappingEntry got %s"%(err.args[0]))
339 def _on_port_mapping_added(self, response):
341 The port mapping was successfully added, return None to the deferred.
345 def _on_no_port_mapping_added(self, failure):
347 Called when the port mapping could not be added. Immediately
348 raise an UPnPError, with the SOAPpy structure inside.
350 @raise UPnPError: When the port mapping could not be added
352 raise UPnPError(failure.value.args[0])
354 def _on_port_mapping_removed(self, response):
356 The port mapping was successfully removed, return None to the deferred.
360 def _on_no_port_mapping_removed(self, failure):
362 Called when the port mapping could not be removed. Immediately
363 raise an UPnPError, with the SOAPpy structure inside.
365 @raise UPnPError: When the port mapping could not be deleted
367 raise UPnPError(failure.value.args[0])
369 # UPNP multicast address, port and request string
370 _UPNP_MCAST = '239.255.255.250'
372 _UPNP_SEARCH_REQUEST = """M-SEARCH * HTTP/1.1\r
374 ST:urn:schemas-upnp-org:device:InternetGatewayDevice:1\r
375 Man:"ssdp:discover"\r
378 """ % (_UPNP_MCAST, _UPNP_PORT)
380 class UPnPProtocol(DatagramProtocol, object):
382 The UPnP Device discovery udp multicast twisted protocol.
385 def __init__(self, *args, **kwargs):
387 Init the protocol, no parameters needed.
389 super(UPnPProtocol, self).__init__(*args, **kwargs)
391 # Url to use to talk to upnp device
392 self._control_url = None
395 #Device discovery deferred
396 self._discovery = None
397 self._discovery_timeout = None
400 def search_device(self):
402 Triggers a UPnP device discovery.
404 The returned deferred will be called with the L{UPnPDevice} that has
405 been found in the LAN.
407 @return: A deferred called with the detected L{UPnPDevice} instance.
408 @rtype: L{twisted.internet.defer.Deferred}
410 self._discovery = defer.Deferred()
416 mcast = reactor.listenMulticast(1900+attempt, self)
418 except CannotListenError:
419 attempt = random.randint(0, 500)
421 # joined multicast group, starting upnp search
422 mcast.joinGroup('239.255.255.250', socket.INADDR_ANY)
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))
428 self._discovery_timeout = reactor.callLater(
429 6, self._on_discovery_timeout)
431 return self._discovery
434 def datagramReceived(self, dgram, address):
436 This is private, handle the multicast answer from the upnp device.
438 logging.debug("Got UPNP multicast search answer:\n%s", dgram)
440 #This is an HTTP response
441 response, message = dgram.split('\r\n', 1)
443 # Prepare status line
444 version, status, textstatus = response.split(None, 2)
446 if not version.startswith('HTTP') or self._control_url != None:
451 # We had a timeout pending, cancel it
452 if self._discovery_timeout != None:
453 self._discovery_timeout.cancel()
454 self._discovery_timeout = None
456 # Launch the info fetching
457 def parse_discovery_response(message):
458 """Separate headers and body from the received http answer."""
463 line, remaining = remaining.split('\r\n', 1)
468 key, val = line.split(':', 1)
470 hdict.setdefault(key, []).append(val.strip())
473 headers, body = parse_discovery_response(message)
475 if not 'location' in headers:
476 self._on_discovery_failed(
478 "No location header in response to M-SEARCH!: %r"%headers))
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)
486 def _on_gateway_response(self, body, loc):
488 Called with the UPnP device XML description fetched via HTTP.
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}.
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
499 if self._control_url != None:
503 upnpinfo = UPnPXml(body)
505 # Check if we have a base url, if not consider location as base url
506 urlbase = upnpinfo.urlbase
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"))
517 self._control_url = urlparse.urljoin(urlbase, controlurl)
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
525 def _on_discovery_failed(self, err):
527 Called when the UPnP Device discovery has failed.
529 The callback returned in L{search_device} is called with
530 an error, corresponding to the cause of the failure.
532 self._control_url = None
533 if self._discovery != None:
534 self._discovery.errback(err)
535 self._discovery = None
537 def _on_discovery_timeout(self):
539 Called when the UPnP Device discovery has timed out.
541 Calls L{_on_discovery_failed}.
543 self._discovery_timeout = None
544 self._on_discovery_failed(UPnPError())