testing/tools/proxyserver/proxyserver.py
author Gregory Szorc <gps@mozilla.com>
Wed, 28 Jan 2015 13:37:00 -0800
branchGECKO170_2012103106_RELBRANCH
changeset 110546 6ed4715ca6580d572af43aa6ec2d2270fd77521f
parent 98529 f4157e8c410708d76703f19e4dfb61859bfe32d8
permissions -rw-r--r--
Close old release branch GECKO170_2012103106_RELBRANCH

# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.

"""
Caching HTTP Proxy for use with the Talos pageload tests
Author: Rob Arnold

This file implements a multithreaded caching http 1.1 proxy. HEAD and GET
methods are supported; POST is not yet.
   
Each incoming request is put onto a new thread; python does not have a thread
pool library, so a new thread is spawned for each request. I have tried to use
the python 2.4 standard library wherever possible.

Caching:
The cache is implemented in the Cache class. Items can only be added to the
cache. The only way to remove items from the cache is to blow it all away,
either by deleting the file (default: proxy_cache.db) or passing the -c or
--clear-cache flags on the command line. It is technically possible to remove
items individually from the cache, but there has been no need to do so so far.

The cache is implemented with the shelve module. The key is the combination of
host, port and request (path + params + fragment) and the values stored are the
http status code, headers and content that were received from the remote server.

Access to the cache is guarded by a semaphore which allows concurrent read
access. The semaphore is guarded by a simple mutex which prevents a deadlock
from occuring when two threads try to add an item to the cache at the same time.

Memory usage is kept to a minimum by the shelve module; only items in the cache
that are currently being served stay in memory.

Proxy:
The BaseHTTPServer.BaseHTTPRequestHandler takes care of parsing incoming
requests and managing the socket connection. See the documentation of the
BaseHTTPServer module for more information. When do_HEAD or do_GET is called,
the url that we are supposed to fetch is in self.path.

TODO:
* Implement POST requests. This requires implementing the do_POST method and
  passing the post data along.
* Implement different cache policies
* Added an interface to allow administrators to probe the cache and remove
  items from the database and such.
"""

__version__ = "0.1"

import os
import sys
import time
import threading
import shelve
from optparse import OptionParser, OptionValueError

import SocketServer
import BaseHTTPServer
import socket
import httplib
from urlparse import urlsplit, urlunsplit

class HTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
  server_version = "TalosProxy/" + __version__
  protocol_version = "HTTP/1.1"

  def do_GET(self):
    content = self.send_head()
    if content:
      try:
        self.wfile.write(content)
      except socket.error, e:
        if options.verbose:
          print "Got socket error %s" % e
    #self.close_connection = 1
  def do_HEAD(self):
    self.send_head()

  def getHeaders(self):
    h = {}
    for name in self.headers.keys():
      h[name] = self.headers[name]

    return h

  def send_head(self, method="GET"): 
    o = urlsplit(self.path)

    #sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

    headers = self.getHeaders()
    for k in "Proxy-Connection", "Connection":
      if k in headers:
        headers[k] = "Close"
    if "Keep-Alive" in headers:
      del headers["Keep-Alive"]

    reqstring = urlunsplit(('','',o.path, o.query, o.fragment))

    if options.no_cache:
      cache_result = None
    else:
      cache_result = cache.get(o.hostname, o.port, reqstring)

    if not cache_result:
      if options.localonly:
        self.send_error(404, "Object not in cache")
        return None
      else:
        if options.verbose:
          print "Object %s was not in the cache" % self.path
        conn = httplib.HTTPConnection(o.netloc)
        conn.request("GET", reqstring, headers=headers)
        res = conn.getresponse()

        content = res.read()
        conn.close()

        status, headers = res.status, res.getheaders()

        if not options.no_cache:
          cache.add(o.hostname, o.port, reqstring, status, headers, content)
    else:
      status, headers, content = cache_result

    try:
      self.send_response(status)
      for name, value in headers:
        # kill the transfer-encoding header because we don't support it when
        # we send data to the client
        if name not in ('transfer-encoding',):
          self.send_header(name, value)
      if "Content-Length" not in headers:
        self.send_header("Content-Length", str(len(content)))
      self.end_headers()
    except socket.error, e:
      if options.verbose:
        print "Got socket error %s" % e
      return None
    return content
  def log_message(self, format, *args):
    if options.verbose:
      BaseHTTPServer.BaseHTTPRequestHandler.log_message(self, format, *args)

class HTTPServer(SocketServer.ThreadingMixIn, BaseHTTPServer.HTTPServer):
  def __init__(self, address, handler):
    BaseHTTPServer.HTTPServer.__init__(self, address, handler)

class Cache(object):
  """Multithreaded cache uses the shelve module to store pages"""
  # 20 concurrent threads ought to be enough for one browser
  max_concurrency = 20
  def __init__(self, name='', max_concurrency=20):
    name = name or options.cache or "proxy_cache.db"
    self.name = name
    self.max_concurrency = max_concurrency
    self.entries = {}
    self.sem = threading.Semaphore(self.max_concurrency)
    self.semlock = threading.Lock()
    if options.clear_cache:
      flag = 'n'
    else:
      flag = 'c'
    self.db = shelve.DbfilenameShelf(name, flag)

  def __del__(self):
    if hasattr(self, 'db'):
      self.db.close()

  def get_key(self, host, port, resource):
    return '%s:%s/%s' % (host, port, resource)

  def get(self, host, port, resource):
    key = self.get_key(host, port, resource)
    self.semlock.acquire()
    self.sem.acquire()
    self.semlock.release()
    try:
      if not self.db.has_key(key):
        return None
      # returns status, headers, content
      return self.db[key]
    finally:
      self.sem.release()
  def add(self, host, port, resource, status, headers, content):
    key = self.get_key(host, port, resource)
    self.semlock.acquire()
    for i in range(self.max_concurrency):
      self.sem.acquire()
    self.semlock.release()
    try:
      self.db[key] = (status, headers, content)
      self.db.sync()
    finally:
      for i in range(self.max_concurrency):
        self.sem.release()

class Options(object):
  port = 8000
  localonly = False
  clear_cache = False
  no_cache = False
  cache = 'proxy_cache.db'
  verbose = False

def _parseOptions():
  def port_callback(option, opt, value, parser):
    if value > 0 and value < (2 ** 16 - 1):
      setattr(parser.values, option.dest, value)
    else:
      raise OptionValueError("Port number is out of range")

  global options
  parser = OptionParser(version="Talos Proxy " + __version__)
  parser.add_option("-p", "--port", dest="port",
    help="The port to run the proxy server on", metavar="PORT", type="int",
    action="callback", callback=port_callback)
  parser.add_option("-v", "--verbose", action="store_true", dest="verbose",
    help="Include additional debugging information")
  parser.add_option("-l", "--localonly", action="store_true", dest="localonly",
    help="Only serve pages from the local database")
  parser.add_option("-c", "--clear", action="store_true", dest="clear_cache",
    help="Clear the cache on startup")
  parser.add_option("-n", "--no-cache", action="store_true", dest="no_cache",
    help="Do not use a cache")
  parser.add_option("-u", "--use-cache", dest="cache",
    help="The filename of the cache to use", metavar="NAME.db")
  parser.set_defaults(verbose=Options.verbose,
                      port=Options.port,
                      localonly=Options.localonly,
                      clear_cache=Options.clear_cache,
                      no_cache=Options.no_cache,
                      cache=Options.cache)
  options, args = parser.parse_args()

"""Configures the proxy server. This should be called before run_proxy. It can be
called afterwards, but note that it is not threadsafe and some options (namely
port) will not take effect"""
def configure_proxy(**kwargs):
  global options
  options = Options()
  for key in kwargs:
    setattr(options, key, kwargs[key])

def _run():
  global cache
  cache = Cache()
  server_address = ('', options.port)
  httpd = HTTPServer(server_address, HTTPRequestHandler)
  httpd.serve_forever()

"""Starts the proxy; it runs on a separate daemon thread"""
def run_proxy():
  thr = threading.Thread(target=_run)
  # now when we die, the daemon thread will die too
  thr.setDaemon(1)
  thr.start()

if __name__ == '__main__':
  _parseOptions()
  try:
    run_proxy()
    # thr.join() doesn't terminate on keyboard interrupt
    while 1: time.sleep(1)
  except KeyboardInterrupt:
    if options.verbose:
      print "Quittin' time..."

__all__ = ['run_proxy', 'configure_proxy']