UpnpPunch.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. import re
  2. import urllib2
  3. import httplib
  4. import logging
  5. from urlparse import urlparse
  6. from xml.dom.minidom import parseString
  7. from xml.parsers.expat import ExpatError
  8. from gevent import socket
  9. import gevent
  10. # Relevant UPnP spec:
  11. # http://www.upnp.org/specs/gw/UPnP-gw-WANIPConnection-v1-Service.pdf
  12. # General TODOs:
  13. # Handle 0 or >1 IGDs
  14. class UpnpError(Exception):
  15. pass
  16. class IGDError(UpnpError):
  17. """
  18. Signifies a problem with the IGD.
  19. """
  20. pass
  21. REMOVE_WHITESPACE = re.compile(r'>\s*<')
  22. def perform_m_search(local_ip):
  23. """
  24. Broadcast a UDP SSDP M-SEARCH packet and return response.
  25. """
  26. search_target = "urn:schemas-upnp-org:device:InternetGatewayDevice:1"
  27. ssdp_request = ''.join(
  28. ['M-SEARCH * HTTP/1.1\r\n',
  29. 'HOST: 239.255.255.250:1900\r\n',
  30. 'MAN: "ssdp:discover"\r\n',
  31. 'MX: 2\r\n',
  32. 'ST: {0}\r\n'.format(search_target),
  33. '\r\n']
  34. )
  35. sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  36. sock.bind((local_ip, 0))
  37. sock.sendto(ssdp_request, ('239.255.255.250', 1900))
  38. if local_ip == "127.0.0.1":
  39. sock.settimeout(1)
  40. else:
  41. sock.settimeout(5)
  42. try:
  43. return sock.recv(2048)
  44. except socket.error:
  45. raise UpnpError("No reply from IGD using {} as IP".format(local_ip))
  46. finally:
  47. sock.close()
  48. def _retrieve_location_from_ssdp(response):
  49. """
  50. Parse raw HTTP response to retrieve the UPnP location header
  51. and return a ParseResult object.
  52. """
  53. parsed_headers = re.findall(r'(?P<name>.*?): (?P<value>.*?)\r\n', response)
  54. header_locations = [header[1]
  55. for header in parsed_headers
  56. if header[0].lower() == 'location']
  57. if len(header_locations) < 1:
  58. raise IGDError('IGD response does not contain a "location" header.')
  59. return urlparse(header_locations[0])
  60. def _retrieve_igd_profile(url):
  61. """
  62. Retrieve the device's UPnP profile.
  63. """
  64. try:
  65. return urllib2.urlopen(url.geturl(), timeout=5).read().decode('utf-8')
  66. except socket.error:
  67. raise IGDError('IGD profile query timed out')
  68. def _get_first_child_data(node):
  69. """
  70. Get the text value of the first child text node of a node.
  71. """
  72. return node.childNodes[0].data
  73. def _parse_igd_profile(profile_xml):
  74. """
  75. Traverse the profile xml DOM looking for either
  76. WANIPConnection or WANPPPConnection and return
  77. the 'controlURL' and the service xml schema.
  78. """
  79. try:
  80. dom = parseString(profile_xml)
  81. except ExpatError as e:
  82. raise IGDError(
  83. 'Unable to parse IGD reply: {0} \n\n\n {1}'.format(profile_xml, e))
  84. service_types = dom.getElementsByTagName('serviceType')
  85. for service in service_types:
  86. if _get_first_child_data(service).find('WANIPConnection') > 0 or \
  87. _get_first_child_data(service).find('WANPPPConnection') > 0:
  88. try:
  89. control_url = _get_first_child_data(
  90. service.parentNode.getElementsByTagName('controlURL')[0])
  91. upnp_schema = _get_first_child_data(service).split(':')[-2]
  92. return control_url, upnp_schema
  93. except IndexError:
  94. # Pass the error because any error here should raise the
  95. # that's specified outside the for loop.
  96. pass
  97. raise IGDError(
  98. 'Could not find a control url or UPNP schema in IGD response.')
  99. # add description
  100. def _get_local_ips():
  101. local_ips = []
  102. try:
  103. # get local ip using UDP and a broadcast address
  104. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  105. s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
  106. # Not using <broadcast> because gevents getaddrinfo doesn't like that
  107. # using port 1 as per hobbldygoop's comment about port 0 not working on osx:
  108. # https://github.com/sirMackk/ZeroNet/commit/fdcd15cf8df0008a2070647d4d28ffedb503fba2#commitcomment-9863928
  109. s.connect(('239.255.255.250', 1))
  110. local_ips.append(s.getsockname()[0])
  111. except:
  112. pass
  113. # Get ip by using UDP and a normal address (google dns ip)
  114. try:
  115. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  116. s.connect(('8.8.8.8', 0))
  117. local_ips.append(s.getsockname()[0])
  118. except:
  119. pass
  120. # Get ip by '' hostname . Not supported on all platforms.
  121. try:
  122. local_ips += socket.gethostbyname_ex('')[2]
  123. except:
  124. pass
  125. # Delete duplicates
  126. local_ips = list(set(local_ips))
  127. # Probably we looking for an ip starting with 192
  128. local_ips = sorted(local_ips, key=lambda a: a.startswith("192"), reverse=True)
  129. return local_ips
  130. def _create_open_message(local_ip,
  131. port,
  132. description="UPnPPunch",
  133. protocol="TCP",
  134. upnp_schema='WANIPConnection'):
  135. """
  136. Build a SOAP AddPortMapping message.
  137. """
  138. soap_message = """<?xml version="1.0"?>
  139. <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
  140. <s:Body>
  141. <u:AddPortMapping xmlns:u="urn:schemas-upnp-org:service:{upnp_schema}:1">
  142. <NewRemoteHost></NewRemoteHost>
  143. <NewExternalPort>{port}</NewExternalPort>
  144. <NewProtocol>{protocol}</NewProtocol>
  145. <NewInternalPort>{port}</NewInternalPort>
  146. <NewInternalClient>{host_ip}</NewInternalClient>
  147. <NewEnabled>1</NewEnabled>
  148. <NewPortMappingDescription>{description}</NewPortMappingDescription>
  149. <NewLeaseDuration>0</NewLeaseDuration>
  150. </u:AddPortMapping>
  151. </s:Body>
  152. </s:Envelope>""".format(port=port,
  153. protocol=protocol,
  154. host_ip=local_ip,
  155. description=description,
  156. upnp_schema=upnp_schema)
  157. return (REMOVE_WHITESPACE.sub('><', soap_message), 'AddPortMapping')
  158. def _create_close_message(local_ip,
  159. port,
  160. description=None,
  161. protocol='TCP',
  162. upnp_schema='WANIPConnection'):
  163. soap_message = """<?xml version="1.0"?>
  164. <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
  165. <s:Body>
  166. <u:DeletePortMapping xmlns:u="urn:schemas-upnp-org:service:{upnp_schema}:1">
  167. <NewRemoteHost></NewRemoteHost>
  168. <NewExternalPort>{port}</NewExternalPort>
  169. <NewProtocol>{protocol}</NewProtocol>
  170. </u:DeletePortMapping>
  171. </s:Body>
  172. </s:Envelope>""".format(port=port,
  173. protocol=protocol,
  174. upnp_schema=upnp_schema)
  175. return (REMOVE_WHITESPACE.sub('><', soap_message), 'DeletePortMapping')
  176. def _parse_for_errors(soap_response):
  177. logging.debug(soap_response.status)
  178. if soap_response.status >= 400:
  179. response_data = soap_response.read()
  180. logging.debug(response_data)
  181. try:
  182. err_dom = parseString(response_data)
  183. err_code = _get_first_child_data(err_dom.getElementsByTagName(
  184. 'errorCode')[0])
  185. err_msg = _get_first_child_data(
  186. err_dom.getElementsByTagName('errorDescription')[0]
  187. )
  188. except Exception as err:
  189. raise IGDError(
  190. 'Unable to parse SOAP error: {0}. Got: "{1}"'.format(
  191. err, response_data))
  192. raise IGDError(
  193. 'SOAP request error: {0} - {1}'.format(err_code, err_msg)
  194. )
  195. return soap_response
  196. def _send_soap_request(location, upnp_schema, control_path, soap_fn,
  197. soap_message):
  198. """
  199. Send out SOAP request to UPnP device and return a response.
  200. """
  201. headers = {
  202. 'SOAPAction': (
  203. '"urn:schemas-upnp-org:service:{schema}:'
  204. '1#{fn_name}"'.format(schema=upnp_schema, fn_name=soap_fn)
  205. ),
  206. 'Content-Type': 'text/xml'
  207. }
  208. logging.debug("Sending UPnP request to {0}:{1}...".format(
  209. location.hostname, location.port))
  210. conn = httplib.HTTPConnection(location.hostname, location.port)
  211. conn.request('POST', control_path, soap_message, headers)
  212. response = conn.getresponse()
  213. conn.close()
  214. return _parse_for_errors(response)
  215. def _collect_idg_data(ip_addr):
  216. idg_data = {}
  217. idg_response = perform_m_search(ip_addr)
  218. idg_data['location'] = _retrieve_location_from_ssdp(idg_response)
  219. idg_data['control_path'], idg_data['upnp_schema'] = _parse_igd_profile(
  220. _retrieve_igd_profile(idg_data['location']))
  221. return idg_data
  222. def _send_requests(messages, location, upnp_schema, control_path):
  223. responses = [_send_soap_request(location, upnp_schema, control_path,
  224. message_tup[1], message_tup[0])
  225. for message_tup in messages]
  226. if all(rsp.status == 200 for rsp in responses):
  227. return
  228. raise UpnpError('Sending requests using UPnP failed.')
  229. def _orchestrate_soap_request(ip, port, msg_fn, desc=None, protos=("TCP", "UDP")):
  230. logging.debug("Trying using local ip: %s" % ip)
  231. idg_data = _collect_idg_data(ip)
  232. soap_messages = [
  233. msg_fn(ip, port, desc, proto, idg_data['upnp_schema'])
  234. for proto in protos
  235. ]
  236. _send_requests(soap_messages, **idg_data)
  237. def _communicate_with_igd(port=15441,
  238. desc="UpnpPunch",
  239. retries=3,
  240. fn=_create_open_message,
  241. protos=("TCP", "UDP")):
  242. """
  243. Manage sending a message generated by 'fn'.
  244. """
  245. local_ips = _get_local_ips()
  246. success = False
  247. def job(local_ip):
  248. for retry in range(retries):
  249. try:
  250. _orchestrate_soap_request(local_ip, port, fn, desc, protos)
  251. return True
  252. except Exception as e:
  253. logging.debug('Upnp request using "{0}" failed: {1}'.format(local_ip, e))
  254. gevent.sleep(1)
  255. return False
  256. threads = []
  257. for local_ip in local_ips:
  258. job_thread = gevent.spawn(job, local_ip)
  259. threads.append(job_thread)
  260. gevent.sleep(0.1)
  261. if any([thread.value for thread in threads]):
  262. success = True
  263. break
  264. # Wait another 10sec for competition or any positibe result
  265. for _ in range(10):
  266. all_done = all([thread.value is not None for thread in threads])
  267. any_succeed = any([thread.value for thread in threads])
  268. if all_done or any_succeed:
  269. break
  270. gevent.sleep(1)
  271. if any([thread.value for thread in threads]):
  272. success = True
  273. if not success:
  274. raise UpnpError(
  275. 'Failed to communicate with igd using port {0} on local machine after {1} tries.'.format(
  276. port, retries))
  277. def ask_to_open_port(port=15441, desc="UpnpPunch", retries=3, protos=("TCP", "UDP")):
  278. logging.debug("Trying to open port %d." % port)
  279. _communicate_with_igd(port=port,
  280. desc=desc,
  281. retries=retries,
  282. fn=_create_open_message,
  283. protos=protos)
  284. def ask_to_close_port(port=15441, desc="UpnpPunch", retries=3, protos=("TCP", "UDP")):
  285. logging.debug("Trying to close port %d." % port)
  286. # retries=1 because multiple successes cause 500 response and failure
  287. _communicate_with_igd(port=port,
  288. desc=desc,
  289. retries=retries,
  290. fn=_create_close_message,
  291. protos=protos)
  292. if __name__ == "__main__":
  293. from gevent import monkey
  294. monkey.patch_all()
  295. logging.getLogger().setLevel(logging.DEBUG)
  296. import time
  297. s = time.time()
  298. print "Opening port..."
  299. print ask_to_open_port(15443, "ZeroNet", protos=["TCP"])
  300. print "Done in", time.time() - s
  301. print "Closing port..."
  302. print ask_to_close_port(15443, "ZeroNet", protos=["TCP"])
  303. print "Done in", time.time() - s