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
20 from twisted.python import failure
22 from nattraverso.pynupnp.soap import SoapProxy
23 from nattraverso.pynupnp.upnpxml import UPnPXml
24 from nattraverso import ipdiscover, portmapper
26 class UPnPError(Exception):
28 A generic UPnP error, with a descriptive message as content.
32 class UPnPMapper(portmapper.NATMapper):
34 This is the UPnP port mapper implementing the
35 L{NATMapper<portmapper.NATMapper>} interface.
37 @see: L{NATMapper<portmapper.NATMapper>}
40 def __init__(self, upnp):
42 Creates the mapper, with the given L{UPnPDevice} instance.
44 @param upnp: L{UPnPDevice} instance
53 self._check_valid_port(port)
55 #Port is already mapped
56 if port in self._mapped:
57 return defer.succeed(self._mapped[port])
59 #Trigger a new mapping creation, first fetch local ip.
60 result = ipdiscover.get_local_ip()
61 self._mapped[port] = result
62 return result.addCallback(self._map_got_local_ip, port)
68 # If the mapping exists, everything's ok
69 if port in self._mapped:
70 return self._mapped[port]
72 raise ValueError('Port %r is not currently mapped'%(port))
74 def unmap(self, port):
78 if port in self._mapped:
79 existing = self._mapped[port]
81 #Pending mapping, queue an unmap,return existing deferred
82 if type(existing) is not tuple:
83 existing.addCallback(lambda x: self.unmap(port))
86 #Remove our local mapping
87 del self._mapped[port]
89 #Ask the UPnP to remove the mapping
90 extaddr, extport = existing
91 return self._upnp.remove_port_mapping(extport, port.getHost().type)
93 raise ValueError('Port %r is not currently mapped'%(port))
95 def get_port_mappings(self):
99 return self._upnp.get_port_mappings()
101 def _map_got_local_ip(self, ip_result, port):
103 We got the local ip address, retreive the existing port mappings
106 @param ip_result: result of L{ipdiscover.get_local_ip}
107 @param port: a L{twisted.internet.interfaces.IListeningPort} we
110 local, ip = ip_result
111 return self._upnp.get_port_mappings().addCallback(
112 self._map_got_port_mappings, ip, port)
114 def _map_got_port_mappings(self, mappings, ip, port):
116 We got all the existing mappings in the device, find an unused one
117 and assign it for the requested port.
119 @param ip: The local ip of this host "x.x.x.x"
120 @param port: a L{twisted.internet.interfaces.IListeningPort} we
122 @param mappings: result of L{UPnPDevice.get_port_mappings}
125 #Get the requested mapping's info
126 ptype = port.getHost().type
127 intport = port.getHost().port
129 for extport in [random.randrange(1025, 65536) for val in range(20)]:
130 # Check if there is an existing mapping, if it does not exist, bingo
131 if not (ptype, extport) in mappings:
134 if (ptype, extport) in mappings:
135 existing = mappings[ptype, extport]
137 local_ip, local_port = existing
138 if local_ip == ip and local_port == intport:
139 # Existing binding for this host/port/proto - replace it
142 # Triggers the creation of the mapping on the device
143 result = self._upnp.add_port_mapping(ip, intport, extport, 'pynupnp', ptype)
145 # We also need the external IP, so we queue first an
146 # External IP Discovery, then we add the mapping.
147 return result.addCallback(
148 lambda x: self._upnp.get_external_ip()).addCallback(
149 self._port_mapping_added, extport, port)
151 def _port_mapping_added(self, extaddr, extport, port):
153 The port mapping was added in the device, this means::
157 > IP:extaddr |> IP:local ip
158 > Port:extport |> Port:port
161 @param extaddr: The exernal ip address
162 @param extport: The external port as number
163 @param port: The internal port as a
164 L{twisted.internet.interfaces.IListeningPort} object, that has been
167 self._mapped[port] = (extaddr, extport)
168 return (extaddr, extport)
172 Represents an UPnP device, with the associated infos, and remote methods.
174 def __init__(self, soap_proxy, info):
176 Build the device, with the given SOAP proxy, and the meta-infos.
178 @param soap_proxy: an initialized L{SoapProxy} to the device
179 @param info: a dictionnary of various infos concerning the
180 device extracted with L{UPnPXml}
182 self._soap_proxy = soap_proxy
185 def get_external_ip(self):
187 Triggers an external ip discovery on the upnp device. Returns
188 a deferred called with the external ip of this host.
190 @return: A deferred called with the ip address, as "x.x.x.x"
191 @rtype: L{twisted.internet.defer.Deferred}
193 result = self._soap_proxy.call('GetExternalIPAddress')
194 result.addCallback(self._on_external_ip)
197 def get_port_mappings(self):
199 Retreive the existing port mappings
201 @see: L{portmapper.NATMapper.get_port_mappings}
202 @return: A deferred called with the dictionnary as defined
203 in the interface L{portmapper.NATMapper.get_port_mappings}
204 @rtype: L{twisted.internet.defer.Deferred}
206 return self._get_port_mapping()
208 def add_port_mapping(self, local_ip, intport, extport, desc, proto, lease=0):
210 Add a port mapping in the upnp device. Returns a deferred.
212 @param local_ip: the LAN ip of this host as "x.x.x.x"
213 @param intport: the internal port number
214 @param extport: the external port number
215 @param desc: the description of this mapping (string)
216 @param proto: "UDP" or "TCP"
217 @param lease: The duration of the lease in (mili)seconds(??)
218 @return: A deferred called with None when the mapping is done
219 @rtype: L{twisted.internet.defer.Deferred}
221 result = self._soap_proxy.call('AddPortMapping', NewRemoteHost="",
222 NewExternalPort=extport,
224 NewInternalPort=intport,
225 NewInternalClient=local_ip,
227 NewPortMappingDescription=desc,
228 NewLeaseDuration=lease)
230 return result.addCallbacks(self._on_port_mapping_added,
231 self._on_no_port_mapping_added)
233 def remove_port_mapping(self, extport, proto):
235 Remove an existing port mapping on the device. Returns a deferred
237 @param extport: the external port number associated to the mapping
239 @param proto: either "UDP" or "TCP"
240 @return: A deferred called with None when the mapping is done
241 @rtype: L{twisted.internet.defer.Deferred}
243 result = self._soap_proxy.call('DeletePortMapping', NewRemoteHost="",
244 NewExternalPort=extport,
247 return result.addCallbacks(self._on_port_mapping_removed,
248 self._on_no_port_mapping_removed)
251 def _on_external_ip(self, res):
253 Called when we received the external ip address from the device.
255 @param res: the SOAPpy structure of the result
256 @return: the external ip string, as "x.x.x.x"
258 logging.debug("Got external ip struct: %r", res)
259 return res['NewExternalIPAddress']
261 def _get_port_mapping(self, mapping_id=0, mappings=None):
263 Fetch the existing mappings starting at index
264 "mapping_id" from the device.
266 To retreive all the mappings call this without parameters.
268 @param mapping_id: The index of the mapping to start fetching from
269 @param mappings: the dictionnary of already fetched mappings
270 @return: A deferred called with the existing mappings when all have been
271 retreived, see L{get_port_mappings}
272 @rtype: L{twisted.internet.defer.Deferred}
277 result = self._soap_proxy.call('GetGenericPortMappingEntry',
278 NewPortMappingIndex=mapping_id)
279 return result.addCallbacks(
280 lambda x: self._on_port_mapping_received(x, mapping_id+1, mappings),
281 lambda x: self._on_no_port_mapping_received( x, mappings))
283 def _on_port_mapping_received(self, response, mapping_id, mappings):
285 Called we we receive a single mapping from the device.
287 @param response: a SOAPpy structure, representing the device's answer
288 @param mapping_id: The index of the next mapping in the device
289 @param mappings: the already fetched mappings, see L{get_port_mappings}
290 @return: A deferred called with the existing mappings when all have been
291 retreived, see L{get_port_mappings}
292 @rtype: L{twisted.internet.defer.Deferred}
294 logging.debug("Got mapping struct: %r", response)
296 response['NewProtocol'], response['NewExternalPort']
297 ] = (response['NewInternalClient'], response['NewInternalPort'])
298 return self._get_port_mapping(mapping_id, mappings)
300 def _on_no_port_mapping_received(self, failure, mappings):
302 Called when we have no more port mappings to retreive, or an
303 error occured while retreiving them.
305 Either we have a "SpecifiedArrayIndexInvalid" SOAP error, and that's ok,
306 it just means we have finished. If it returns some other error, then we
307 fail with an UPnPError.
309 @param mappings: the already retreived mappings
310 @param failure: the failure
311 @return: The existing mappings as defined in L{get_port_mappings}
312 @raise UPnPError: When we got any other error
313 than "SpecifiedArrayIndexInvalid"
315 logging.debug("_on_no_port_mapping_received: %s", failure)
317 message = err.args[0]["UPnPError"]["errorDescription"]
318 if "SpecifiedArrayIndexInvalid" == message:
324 def _on_port_mapping_added(self, response):
326 The port mapping was successfully added, return None to the deferred.
330 def _on_no_port_mapping_added(self, failure):
332 Called when the port mapping could not be added. Immediately
333 raise an UPnPError, with the SOAPpy structure inside.
335 @raise UPnPError: When the port mapping could not be added
339 def _on_port_mapping_removed(self, response):
341 The port mapping was successfully removed, return None to the deferred.
345 def _on_no_port_mapping_removed(self, failure):
347 Called when the port mapping could not be removed. Immediately
348 raise an UPnPError, with the SOAPpy structure inside.
350 @raise UPnPError: When the port mapping could not be deleted
354 # UPNP multicast address, port and request string
355 _UPNP_MCAST = '239.255.255.250'
357 _UPNP_SEARCH_REQUEST = """M-SEARCH * HTTP/1.1\r
359 ST:urn:schemas-upnp-org:device:InternetGatewayDevice:1\r
360 Man:"ssdp:discover"\r
363 """ % (_UPNP_MCAST, _UPNP_PORT)
365 class UPnPProtocol(DatagramProtocol, object):
367 The UPnP Device discovery udp multicast twisted protocol.
370 def __init__(self, *args, **kwargs):
372 Init the protocol, no parameters needed.
374 super(UPnPProtocol, self).__init__(*args, **kwargs)
376 #Device discovery deferred
377 self._discovery = None
378 self._discovery_timeout = None
383 def search_device(self):
385 Triggers a UPnP device discovery.
387 The returned deferred will be called with the L{UPnPDevice} that has
388 been found in the LAN.
390 @return: A deferred called with the detected L{UPnPDevice} instance.
391 @rtype: L{twisted.internet.defer.Deferred}
393 if self._discovery is not None:
394 raise ValueError('already used')
395 self._discovery = defer.Deferred()
396 self._discovery_timeout = reactor.callLater(6, self._on_discovery_timeout)
402 self.mcast = reactor.listenMulticast(1900+attempt, self)
404 except CannotListenError:
405 attempt = random.randint(0, 500)
407 # joined multicast group, starting upnp search
408 self.mcast.joinGroup('239.255.255.250', socket.INADDR_ANY)
410 self.transport.write(_UPNP_SEARCH_REQUEST, (_UPNP_MCAST, _UPNP_PORT))
411 self.transport.write(_UPNP_SEARCH_REQUEST, (_UPNP_MCAST, _UPNP_PORT))
412 self.transport.write(_UPNP_SEARCH_REQUEST, (_UPNP_MCAST, _UPNP_PORT))
414 return self._discovery
417 def datagramReceived(self, dgram, address):
421 This is private, handle the multicast answer from the upnp device.
423 logging.debug("Got UPNP multicast search answer:\n%s", dgram)
425 #This is an HTTP response
426 response, message = dgram.split('\r\n', 1)
428 # Prepare status line
429 version, status, textstatus = response.split(None, 2)
431 if not version.startswith('HTTP'):
436 # Launch the info fetching
437 def parse_discovery_response(message):
438 """Separate headers and body from the received http answer."""
443 line, remaining = remaining.split('\r\n', 1)
448 key, val = line.split(':', 1)
450 hdict.setdefault(key, []).append(val.strip())
453 headers, body = parse_discovery_response(message)
455 if not 'location' in headers:
456 self._on_discovery_failed(
458 "No location header in response to M-SEARCH!: %r"%headers))
461 loc = headers['location'][0]
462 result = client.getPage(url=loc)
463 result.addCallback(self._on_gateway_response, loc).addErrback(self._on_discovery_failed)
465 def _on_gateway_response(self, body, loc):
469 Called with the UPnP device XML description fetched via HTTP.
471 If the device has suitable services for ip discovery and port mappings,
472 the callback returned in L{search_device} is called with
473 the discovered L{UPnPDevice}.
475 @raise UPnPError: When no suitable service has been
476 found in the description, or another error occurs.
477 @param body: The xml description of the device.
478 @param loc: the url used to retreive the xml description
482 upnpinfo = UPnPXml(body)
484 # Check if we have a base url, if not consider location as base url
485 urlbase = upnpinfo.urlbase
489 # Check the control url, if None, then the device cannot do what we want
490 controlurl = upnpinfo.controlurl
491 if controlurl == None:
492 self._on_discovery_failed(UPnPError("upnp response showed no WANConnections"))
495 control_url2 = urlparse.urljoin(urlbase, controlurl)
496 soap_proxy = SoapProxy(control_url2, upnpinfo.wanservice)
497 self._on_discovery_succeeded(UPnPDevice(soap_proxy, upnpinfo.deviceinfos))
499 def _on_discovery_succeeded(self, res):
503 self.mcast.stopListening()
504 self._discovery_timeout.cancel()
505 self._discovery.callback(res)
507 def _on_discovery_failed(self, err):
511 self.mcast.stopListening()
512 self._discovery_timeout.cancel()
513 self._discovery.errback(err)
515 def _on_discovery_timeout(self):
519 self.mcast.stopListening()
520 self._discovery.errback(failure.Failure(defer.TimeoutError('in _on_discovery_timeout')))
522 def search_upnp_device ():
524 Check the network for an UPnP device. Returns a deferred
525 with the L{UPnPDevice} instance as result, if found.
527 @return: A deferred called with the L{UPnPDevice} instance
528 @rtype: L{twisted.internet.defer.Deferred}
530 return defer.maybeDeferred(UPnPProtocol().search_device)