Skip to content

Commit

Permalink
Merge pull request #53 from sirMackk/master
Browse files Browse the repository at this point in the history
Python-only Upnp solution
  • Loading branch information
HelloZeroNet committed Feb 25, 2015
2 parents e8368a8 + 218ceb4 commit 34f6d1e
Show file tree
Hide file tree
Showing 11 changed files with 213 additions and 742 deletions.
3 changes: 0 additions & 3 deletions src/Config.py
Expand Up @@ -19,10 +19,8 @@ def __str__(self):
def createArguments(self):
# Platform specific
if sys.platform.startswith("win"):
upnpc = "tools\\upnpc\\upnpc-shared.exe"
coffeescript = "type %s | tools\\coffee\\coffee.cmd"
else:
upnpc = None
coffeescript = None

# Create parser
Expand Down Expand Up @@ -81,7 +79,6 @@ def createArguments(self):
parser.add_argument('--fileserver_port',help='FileServer bind port', default=15441, type=int, metavar='port')

parser.add_argument('--ip_external', help='External ip (tested on start if None)', metavar='ip')
parser.add_argument('--upnpc', help='MiniUPnP binary for open port on router', default=upnpc, metavar='executable_path')

parser.add_argument('--coffeescript_compiler', help='Coffeescript compiler for developing', default=coffeescript, metavar='executable_path')

Expand Down
29 changes: 11 additions & 18 deletions src/File/FileServer.py
Expand Up @@ -6,6 +6,7 @@
from Site import SiteManager
from Debug import Debug
from Connection import ConnectionServer
from util import UpnpPunch


class FileServer(ConnectionServer):
Expand Down Expand Up @@ -44,24 +45,16 @@ def openport(self, port=None, check=True):
if self.testOpenport(port)["result"] == True:
return True # Port already opened

if config.upnpc: # If we have upnpc util, try to use it to puch port on our router
self.log.info("Try to open port using upnpc...")
try:
exit = os.system("%s -e ZeroNet -r %s tcp" % (config.upnpc, self.port))
if exit == 0: # Success
upnpc_success = True
else: # Failed
exit = os.system("%s -r %s tcp" % (config.upnpc, self.port)) # Try without -e option
if exit == 0:
upnpc_success = True
else:
upnpc_success = False
except Exception, err:
self.log.error("Upnpc run error: %s" % Debug.formatException(err))
upnpc_success = False

if upnpc_success and self.testOpenport(port)["result"] == True:
return True
self.log.info("Trying to open port using UpnpPunch...")
try:
upnp_punch = UpnpPunch.open_port(self.port, 'ZeroNet')
upnp_punch = True
except Exception, err:
self.log.error("UpnpPunch run error: %s" % Debug.formatException(err))
upnp_punch = False

if upnp_punch and self.testOpenport(port)["result"] == True:
return True

self.log.info("Upnp mapping failed :( Please forward port %s on your router to your ipaddress" % port)
return False
Expand Down
202 changes: 202 additions & 0 deletions src/util/UpnpPunch.py
@@ -0,0 +1,202 @@
import gevent
from gevent import socket
from gevent import monkey

monkey.patch_socket()

import re, urllib2, httplib
from urlparse import urlparse
from xml.dom.minidom import parseString

# Relevant UPnP spec: http://www.upnp.org/specs/gw/UPnP-gw-WANIPConnection-v1-Service.pdf

# General TODOs:
# Handle 0 or >1 IGDs

remove_whitespace = re.compile(r'>\s*<')


def _m_search_ssdp():
"""
Broadcast a UDP SSDP M-SEARCH packet and return response.
"""
search_target = "urn:schemas-upnp-org:device:InternetGatewayDevice:1"

ssdp_request = ''.join(
['M-SEARCH * HTTP/1.1\r\n',
'HOST: 239.255.255.250:1900\r\n',
'MAN: "ssdp:discover"\r\n',
'MX: 2\r\n',
'ST: {0}\r\n'.format(search_target),
'\r\n']
)

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.sendto(ssdp_request, ('239.255.255.250', 1900))
sock.settimeout(5)

try:
data = sock.recv(2048)
except SocketError:
# socket has stopped reading on windows
pass

return data


def _retrieve_location_from_ssdp(response):
"""
Parse raw HTTP response to retrieve the UPnP location header
and return a ParseResult object.
"""
parsed = re.findall(r'(?P<name>.*?): (?P<value>.*?)\r\n', response)
location_header = filter(lambda x: x[0].lower() == 'location', parsed)

if not len(location_header):
# no location header returned :(
return False

return urlparse(location_header[0][1])


def _retrieve_igd_profile(url):
"""
Retrieve the device's UPnP profile.
"""
return urllib2.urlopen(url.geturl()).read()


def _node_val(node):
"""
Get the text value of the first child text node of a node.
"""
return node.childNodes[0].data


def _parse_igd_profile(profile_xml):
"""
Traverse the profile xml DOM looking for either
WANIPConnection or WANPPPConnection and return
the value found as well as the 'controlURL'.
"""
dom = parseString(profile_xml)

service_types = dom.getElementsByTagName('serviceType')
for service in service_types:
if _node_val(service).find('WANIPConnection') > 0 or \
_node_val(service).find('WANPPPConnection') > 0:
control_url = service.parentNode.getElementsByTagName(
'controlURL'
)[0].childNodes[0].data
upnp_schema = _node_val(service).split(':')[-2]
return control_url, upnp_schema

return False


def _get_local_ip():
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
# not using <broadcast> because gevents getaddrinfo doesn't like that
# using port 1 as per hobbldygoop's comment about port 0 not working on osx:
# https://github.com/sirMackk/ZeroNet/commit/fdcd15cf8df0008a2070647d4d28ffedb503fba2#commitcomment-9863928
s.connect(('239.255.255.250', 1))
return s.getsockname()[0]


def _create_soap_message(port, description="UPnPPunch", protocol="TCP",
upnp_schema='WANIPConnection'):
"""
Build a SOAP AddPortMapping message.
"""
current_ip = _get_local_ip()

soap_message = """<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:AddPortMapping xmlns:u="urn:schemas-upnp-org:service:{upnp_schema}:1">
<NewRemoteHost></NewRemoteHost>
<NewExternalPort>{port}</NewExternalPort>
<NewProtocol>{protocol}</NewProtocol>
<NewInternalPort>{port}</NewInternalPort>
<NewInternalClient>{host_ip}</NewInternalClient>
<NewEnabled>1</NewEnabled>
<NewPortMappingDescription>{description}</NewPortMappingDescription>
<NewLeaseDuration>0</NewLeaseDuration>
</u:AddPortMapping>
</s:Body>
</s:Envelope>""".format(port=port,
protocol=protocol,
host_ip=current_ip,
description=description,
upnp_schema=upnp_schema)
return remove_whitespace.sub('><', soap_message)


def _parse_for_errors(soap_response):
if soap_response.status == 500:
err_dom = parseString(soap_response.read())
err_code = _node_val(err_dom.getElementsByTagName('errorCode')[0])
err_msg = _node_val(
err_dom.getElementsByTagName('errorDescription')[0]
)
raise Exception(
'SOAP request error: {0} - {1}'.format(err_code, err_msg)
)
return False
else:
return True


def _send_soap_request(location, upnp_schema, control_url, soap_message):
"""
Send out SOAP request to UPnP device and return a response.
"""
headers = {
'SOAPAction': (
'"urn:schemas-upnp-org:service:{schema}:'
'1#AddPortMapping"'.format(schema=upnp_schema)
),
'Content-Type': 'text/xml'
}
conn = httplib.HTTPConnection(location.hostname, location.port)
conn.request('POST', control_url, soap_message, headers)

response = conn.getresponse()
conn.close()

return _parse_for_errors(response)


def open_port(port=15441, desc="UpnpPunch"):
"""
Attempt to forward a port using UPnP.
"""

location = _retrieve_location_from_ssdp(_m_search_ssdp())

if not location:
return False

parsed = _parse_igd_profile(
_retrieve_igd_profile(location)
)

if not parsed:
return False

control_url, upnp_schema = parsed

soap_messages = [_create_soap_message(port, desc, proto, upnp_schema)
for proto in ['TCP', 'UDP']]

requests = [gevent.spawn(
_send_soap_request, location, upnp_schema, control_url, message
) for message in soap_messages]

gevent.joinall(requests, timeout=3)

if all(requests):
return True
else:
return False

0 comments on commit 34f6d1e

Please sign in to comment.