Skip to content

Instantly share code, notes, and snippets.

Created December 11, 2011 08:09
Squashed commit for UPnP support.
From 97ffd4c14523dcb945ebca7de5d3b00fee8f195d Mon Sep 17 00:00:00 2001
From: Kamran Riaz Khan <krkhan@inspirated.com>
Date: Sun, 30 Oct 2011 13:11:22 +0500
Subject: [PATCH] Squashed commit for adding UPnP support to conn panel.
Discover UPnP root devices on LAN and print their IPs in
conn panel.
Parse device description to print manufacturer and UPC.
List port mappings via SOAP.
Add port mapping for (interface ip, OR port) if possible.
---
src/cli/connections/connPanel.py | 59 +++++++++++++-
src/util/connections.py | 102 ++++++++++++++++++++++++
src/util/upnp.py | 160 ++++++++++++++++++++++++++++++++++++++
3 files changed, 316 insertions(+), 5 deletions(-)
create mode 100644 src/util/upnp.py
diff --git a/src/cli/connections/connPanel.py b/src/cli/connections/connPanel.py
index 3afed82..5216d22 100644
--- a/src/cli/connections/connPanel.py
+++ b/src/cli/connections/connPanel.py
@@ -11,7 +11,7 @@ import cli.popups
import cli.connections.exitNodeDialog
from cli.connections import countPopup, descriptorPopup, entries, connEntry, circEntry
-from util import connections, enum, panel, torTools, uiTools
+from util import connections, enum, log, panel, torTools, uiTools
DEFAULT_CONFIG = {"features.connection.resolveApps": True,
"features.connection.listingType": 0,
@@ -61,9 +61,11 @@ class ConnectionPanel(panel.Panel, threading.Thread):
self._title = "Connections:" # title line of the panel
self._entries = [] # last fetched display entries
self._entryLines = [] # individual lines rendered from the entries listing
+ self._upnpDevices = [] # upnp devices found on network
self._showDetails = False # presents the details panel if true
self._lastUpdate = -1 # time the content was last revised
+ self._lastUpnpResolve = -1 # time the upnp resolver was last queried
self._isTorRunning = True # indicates if tor is currently running or not
self._haltTime = None # time when tor was stopped
self._halt = False # terminates thread if true
@@ -106,6 +108,15 @@ class ConnectionPanel(panel.Panel, threading.Thread):
# rate limits appResolver queries to once per update
self.appResolveSinceUpdate = False
+ # resolver for the command/pid associated with SOCKS, HIDDEN, and CONTROL connections
+ self._upnpResolver = connections.UpnpResolver()
+
+ # rate limits appResolver queries to once per update
+ self.upnpResolveSinceUpdate = False
+
+ # rate limits appResolver queries to once per update
+ self.upnpResolveSinceUpdate = False
+
# mark the initially exitsing connection uptimes as being estimates
for entry in self._entries:
if isinstance(entry, connEntry.ConnectionEntry):
@@ -284,6 +295,7 @@ class ConnectionPanel(panel.Panel, threading.Thread):
self._cond.release()
self._update() # populates initial entries
self._resolveApps(False) # resolves initial applications
+ self._resolveUpnp(False)
while not self._halt:
currentTime = time.time()
@@ -336,14 +348,15 @@ class ConnectionPanel(panel.Panel, threading.Thread):
def draw(self, width, height):
self.valsLock.acquire()
+ listHeight = height - 3
# if we don't have any contents then refuse to show details
if not self._entries: self._showDetails = False
# extra line when showing the detail panel is for the bottom border
detailPanelOffset = DETAILS_HEIGHT + 1 if self._showDetails else 0
- isScrollbarVisible = len(self._entryLines) > height - detailPanelOffset - 1
+ isScrollbarVisible = len(self._entryLines) > listHeight - detailPanelOffset - 1
- scrollLoc = self._scroller.getScrollLoc(self._entryLines, height - detailPanelOffset - 1)
+ scrollLoc = self._scroller.getScrollLoc(self._entryLines, listHeight - detailPanelOffset - 1)
cursorSelection = self.getSelection()
# draws the detail panel if currently displaying it
@@ -365,7 +378,8 @@ class ConnectionPanel(panel.Panel, threading.Thread):
scrollOffset = 0
if isScrollbarVisible:
scrollOffset = 2
- self.addScrollBar(scrollLoc, scrollLoc + height - detailPanelOffset - 1, len(self._entryLines), 1 + detailPanelOffset)
+ self.addScrollBar(scrollLoc, scrollLoc + listHeight - detailPanelOffset - 1,
+ len(self._entryLines), 1 + detailPanelOffset, listHeight)
if self.isPaused() or not self._isTorRunning:
currentTime = self.getPauseTime()
@@ -378,6 +392,10 @@ class ConnectionPanel(panel.Panel, threading.Thread):
# resolution for the applicaitions they belong to
if isinstance(entryLine, connEntry.ConnectionLine) and entryLine.isUnresolvedApp():
self._resolveApps()
+
+ if currentTime - self._lastUpnpResolve > 10:
+ self._resolveUpnp()
+ self._lastUpnpResolve = currentTime
# hilighting if this is the selected line
extraFormat = curses.A_STANDOUT if entryLine == cursorSelection else curses.A_NORMAL
@@ -396,7 +414,17 @@ class ConnectionPanel(panel.Panel, threading.Thread):
self.addstr(drawLine, xOffset, msg, attr)
xOffset += len(msg)
- if drawLine >= height: break
+ if drawLine >= listHeight: break
+
+ xOffset = 3 if isScrollbarVisible else 0
+ if self._upnpDevices:
+ msg = "UPnP device%s available: " % ("s" if len(self._upnpDevices) > 1 else "")
+ self.addstr(listHeight + 2, xOffset, msg, curses.A_STANDOUT | uiTools.getColor('green'))
+ xOffset = xOffset + len(msg)
+ msg = ", ".join([device.name for device in self._upnpDevices])
+ self.addstr(listHeight + 2, xOffset, msg, curses.A_STANDOUT | uiTools.getColor('green'))
+ else:
+ self.addstr(listHeight + 2, xOffset, "UPnP not available", curses.A_STANDOUT | uiTools.getColor('red'))
self.valsLock.release()
@@ -416,6 +444,7 @@ class ConnectionPanel(panel.Panel, threading.Thread):
"""
self.appResolveSinceUpdate = False
+ self.upnpResolveSinceUpdate = False
# if we don't have an initialized resolver then this is a no-op
if not connections.isResolverAlive("tor"): return
@@ -578,3 +607,23 @@ class ConnectionPanel(panel.Panel, threading.Thread):
if flagQuery:
self.appResolveSinceUpdate = True
+ def _resolveUpnp(self, flagQuery = True):
+ """
+ Triggers an asynchronous query for resolving gateway routers.
+
+ Arguments:
+ flagQuery - sets a flag to prevent further call from being respected
+ until the next update if true
+ """
+
+ if self.upnpResolveSinceUpdate: return
+
+ if not self._upnpResolver.isResolving:
+ self._upnpResolver.resolve()
+
+ self._upnpDevices = self._upnpResolver.getDevices(0.2)
+
+ if flagQuery:
+ self.appResolveSinceUpdate = True
+
+
diff --git a/src/util/connections.py b/src/util/connections.py
index 250b113..9d65296 100644
--- a/src/util/connections.py
+++ b/src/util/connections.py
@@ -17,7 +17,11 @@ options that perform even better (thanks to Fabian Keil and Hans Schnehl):
- procstat procstat -f <pid> | grep TCP | grep -v 0.0.0.0:0
"""
+import fcntl
import os
+import select
+import struct
+import socket
import time
import threading
@@ -763,3 +767,101 @@ class AppResolver:
self._cond.notifyAll()
self._cond.release()
+class UpnpResolver(threading.Thread):
+ """
+ Provides and controls gateway routers on local network.
+ """
+
+ def __init__(self):
+ """
+ Constructs a resolver instance.
+ """
+ threading.Thread.__init__(self)
+ self.setDaemon(True)
+
+ self.queryDevices = {}
+ self.devicesLock = threading.RLock()
+ self._cond = threading.Condition() # used for pausing when waiting for devices
+ self.isResolving = False # flag set if we're in the process of making a query
+ self.failureCount = 0 # -1 if we've made a successful query
+
+ def getDevices(self, maxWait=0):
+ """
+ Provides the last queried devices. If we're in the process of making a
+ query then we can optionally block for a time to see if it finishes.
+
+ Arguments:
+ maxWait - maximum second duration to block on getting devices before
+ returning
+ """
+
+ self._cond.acquire()
+ if self.isResolving and maxWait > 0:
+ self._cond.wait(maxWait)
+ self._cond.release()
+
+ self.devicesLock.acquire()
+ devices = self.queryDevices
+ self.devicesLock.release()
+
+ return devices
+
+ def resolve(self):
+ """
+ This clears the last set of devices when completed.
+ """
+
+ if self.failureCount < 3:
+ self.isResolving = True
+ t = threading.Thread(target = self._queryRootDevices)
+ t.setDaemon(True)
+ t.start()
+
+ def _queryRootDevices(self):
+ """
+ Discovers root devices via SSDP.
+ """
+
+ # upnp uses torTools, torTools uses isValidIpAddress(), import deferred until here
+ # to resolve circular dependency
+ # TODO: refactor modules to move this import to top
+
+ from util import upnp
+
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 5)
+
+ sock.bind(('', 1900))
+
+ msg = ('M-SEARCH * HTTP/1.1\r\n'
+ 'ST: upnp:rootdevice\r\n'
+ 'MX: 3\r\n'
+ 'MAN: "ssdp:discover"\r\n'
+ 'HOST: 239.255.255.250:1900\r\n'
+ '\r\n')
+ sock.sendto(msg, ('239.255.255.250', 1900))
+ sock.setblocking(0)
+ ready = select.select([sock], [], [], 5)
+ devices = []
+ if ready[0]:
+ response, (ip, port) = sock.recvfrom(1024)
+
+ device = upnp.UpnpDevice(ip, response)
+ if device.name:
+ devices.append(device)
+ self.failureCount = -1
+ else:
+ self.failureCount = self.failureCount + 1
+
+ self.devicesLock.acquire()
+ self.queryDevices = devices
+ self.isResolving = False
+ self.devicesLock.release()
+
+ # wakes threads waiting on devices
+ self._cond.acquire()
+ self._cond.notifyAll()
+ self._cond.release()
+
+
diff --git a/src/util/upnp.py b/src/util/upnp.py
new file mode 100644
index 0000000..0225da6
--- /dev/null
+++ b/src/util/upnp.py
@@ -0,0 +1,160 @@
+"""
+Discovers and controls gateway routers via UPNP.
+"""
+
+import re
+import socket
+import sys
+import time
+import httplib
+import urllib
+import urllib2
+
+from xml.etree.ElementTree import ElementTree
+
+from util import log, torTools
+
+SOAPXML = '''<?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:%(action)s xmlns:u="%(xmlns)s">
+%(argData)s
+</u:%(action)s>
+</s:Body>
+</s:Envelope>'''
+
+def create_soap_xml(xmlns, action, args={}):
+ argData = ''
+ for key, value in args.items():
+ argData += '<%s>%s</%s>\n' % (key, value, key)
+ xml = SOAPXML % { 'xmlns' : xmlns, 'action' : action, 'argData' : argData }
+ return xml
+
+class UPnPError(Exception):
+ def __init__(self, errorCode, errorDescription):
+ self.errorCode = errorCode
+ self.errorDescription = errorDescription
+ def __str__(self):
+ return 'UPnP Error (%s): %s' % (self.errorCode, self.errorDescription)
+
+class UpnpDevice(object):
+ def __init__(self, ip, response):
+ self.ip = ip
+ self.response = response
+ self.name = None
+ self.desc = {}
+ self.control = {}
+
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+ sock.connect(('18.0.0.1', 9))
+ self.ifip, _ = sock.getsockname()
+
+ conn = torTools.getConn()
+ self.orport = conn.getOption('ORPort', 0)
+
+ match = re.findall(r"LOCATION: (.*)", response)
+ if not match:
+ return
+ location = match[0].strip()
+
+ fd = None
+ try:
+ fd = urllib2.urlopen(location)
+ except urllib2.URLError:
+ self.name = self.ip
+ else:
+ self.parse_desc(fd)
+
+ if 'manufacturer' in self.desc and 'UPC' in self.desc:
+ self.name = "%s %s found at %s" % (self.desc['manufacturer'],
+ self.desc['UPC'], self.ip)
+
+ if 'url' in self.control:
+ self.get_port_mappings()
+ self.add_port_mapping()
+
+ def parse_desc(self, fd):
+ tree = ElementTree(file=fd)
+
+ prefix = ''
+ match = re.findall(r'({urn:.*})', tree.getroot().tag)
+ if match:
+ prefix = match[0]
+
+ for elem in tree.getiterator():
+ for key in ('URLBase', 'manufacturer', 'UPC'):
+ if elem.tag == prefix + key:
+ self.desc[key] = elem.text
+
+ if elem.tag == prefix + 'service':
+ service = dict([(e.tag.replace(prefix, ''), e.text) for e in elem.getchildren()])
+
+ if 'WANIPConnection' in service['serviceType']:
+ self.control['serviceType'] = service['serviceType']
+ self.control['url'] = service['controlURL']
+
+ def get_port_mappings(self):
+ action = 'GetGenericPortMappingEntry'
+
+ for i in range(3):
+ args = { 'NewPortMappingIndex' : i }
+ try:
+ retvals = self.get_soap_response(action, args)
+ except UPnPError, e:
+ pass
+ else:
+ log.log(log.NOTICE, 'Port mapping found: ' + retvals['NewPortMappingDescription'])
+
+ def add_port_mapping(self):
+ if self.orport == '0':
+ return
+
+ action = 'AddPortMapping'
+ args = {
+ 'NewRemoteHost' : '',
+ 'NewExternalPort' : self.orport,
+ 'NewProtocol' : 'TCP',
+ 'NewInternalPort' : self.orport,
+ 'NewInternalClient' : self.ifip,
+ 'NewEnabled' : '1',
+ 'NewPortMappingDescription' : 'Tor Relay',
+ 'NewLeaseDuration' : '0',
+ }
+
+ try:
+ self.get_soap_response(action, args)
+ except UPnPError, e:
+ pass
+ else:
+ log.log(log.NOTICE, 'Port mapping added for %s:%s' % (self.ifip, self.orport))
+
+ def get_soap_response(self, action, args):
+ headers = {
+ 'Content-Type' : 'text/xml',
+ 'SOAPAction': '"' + self.control['serviceType'] + '#' + action + '"',
+ }
+ xml = create_soap_xml(xmlns=self.control['serviceType'],
+ action=action,
+ args=args)
+
+ conn = httplib.HTTPConnection(self.desc['URLBase'].replace('http://', ''))
+ conn.request('POST', self.control['url'], xml, headers)
+ response = conn.getresponse()
+
+ tree = ElementTree(file=response)
+
+ retvals = {}
+ for elem in tree.getiterator():
+ if 'Response' in elem.tag:
+ retvals = dict([(c.tag, c.text) for c in elem.getchildren()])
+ if 'UPnPError' in elem.tag:
+ errorCode, errorDescription = -1, ''
+ for c in elem.getchildren():
+ if 'errorCode' in c.tag:
+ errorCode = c.text
+ if 'errorDescription' in c.tag:
+ errorDescription = c.text
+ raise UPnPError(errorCode, errorDescription)
+
+ return retvals
+
--
1.7.7.3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment