blob: 58e2ba000d8cce73cb3a48bff7247e77dcfd17b7 [file] [log] [blame]
#!/usr/bin/env python
# Copyright (c) 2012 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Constrained Network Server. Serves files with supplied network constraints.
The CNS exposes a web based API allowing network constraints to be imposed on
file serving.
TODO(dalecurtis): Add some more docs here.
"""
import logging
from logging import handlers
import mimetypes
import optparse
import os
import signal
import sys
import threading
import time
import urllib
import urllib2
import traffic_control
try:
import cherrypy
except ImportError:
print ('CNS requires CherryPy v3 or higher to be installed. Please install\n'
'and try again. On Linux: sudo apt-get install python-cherrypy3\n')
sys.exit(1)
# Add webm file types to mimetypes map since cherrypy's default type is text.
mimetypes.types_map['.webm'] = 'video/webm'
# Default logging is ERROR. Use --verbose to enable DEBUG logging.
_DEFAULT_LOG_LEVEL = logging.ERROR
# Default port to serve the CNS on.
_DEFAULT_SERVING_PORT = 9000
# Default port range for constrained use.
_DEFAULT_CNS_PORT_RANGE = (50000, 51000)
# Default number of seconds before a port can be torn down.
_DEFAULT_PORT_EXPIRY_TIME_SECS = 5 * 60
class PortAllocator(object):
"""Dynamically allocates/deallocates ports with a given set of constraints."""
def __init__(self, port_range, expiry_time_secs=5 * 60):
"""Sets up initial state for the Port Allocator.
Args:
port_range: Range of ports available for allocation.
expiry_time_secs: Amount of time in seconds before constrained ports are
cleaned up.
"""
self._port_range = port_range
self._expiry_time_secs = expiry_time_secs
# Keeps track of ports we've used, the creation key, and the last request
# time for the port so they can be cached and cleaned up later.
self._ports = {}
# Locks port creation and cleanup. TODO(dalecurtis): If performance becomes
# an issue a per-port based lock system can be used instead.
self._port_lock = threading.RLock()
def Get(self, key, new_port=False, **kwargs):
"""Sets up a constrained port using the requested parameters.
Requests for the same key and constraints will result in a cached port being
returned if possible, subject to new_port.
Args:
key: Used to cache ports with the given constraints.
new_port: Whether to create a new port or use an existing one if possible.
**kwargs: Constraints to pass into traffic control.
Returns:
None if no port can be setup or the port number of the constrained port.
"""
with self._port_lock:
# Check port key cache to see if this port is already setup. Update the
# cache time and return the port if so. Performance isn't a concern here,
# so just iterate over ports dict for simplicity.
full_key = (key,) + tuple(kwargs.values())
if not new_port:
for port, status in self._ports.iteritems():
if full_key == status['key']:
self._ports[port]['last_update'] = time.time()
return port
# Cleanup ports on new port requests. Do it after the cache check though
# so we don't erase and then setup the same port.
if self._expiry_time_secs > 0:
self.Cleanup(all_ports=False)
# Performance isn't really an issue here, so just iterate over the port
# range to find an unused port. If no port is found, None is returned.
for port in xrange(self._port_range[0], self._port_range[1]):
if port in self._ports:
continue
if self._SetupPort(port, **kwargs):
kwargs['port'] = port
self._ports[port] = {'last_update': time.time(), 'key': full_key,
'config': kwargs}
return port
def _SetupPort(self, port, **kwargs):
"""Setup network constraints on port using the requested parameters.
Args:
port: The port number to setup network constraints on.
**kwargs: Network constraints to set up on the port.
Returns:
True if setting the network constraints on the port was successful, false
otherwise.
"""
kwargs['port'] = port
try:
cherrypy.log('Setting up port %d' % port)
traffic_control.CreateConstrainedPort(kwargs)
return True
except traffic_control.TrafficControlError as e:
cherrypy.log('Error: %s\nOutput: %s' % (e.msg, e.error))
return False
def Cleanup(self, all_ports, request_ip=None):
"""Cleans up expired ports, or if all_ports=True, all allocated ports.
By default, ports which haven't been used for self._expiry_time_secs are
torn down. If all_ports=True then they are torn down regardless.
Args:
all_ports: Should all ports be torn down regardless of expiration?
request_ip: Tear ports matching the IP address regarless of expiration.
"""
with self._port_lock:
now = time.time()
# Use .items() instead of .iteritems() so we can delete keys w/o error.
for port, status in self._ports.items():
expired = now - status['last_update'] > self._expiry_time_secs
matching_ip = request_ip and status['key'][0].startswith(request_ip)
if all_ports or expired or matching_ip:
cherrypy.log('Cleaning up port %d' % port)
self._DeletePort(port)
del self._ports[port]
def _DeletePort(self, port):
"""Deletes network constraints on port.
Args:
port: The port number associated with the network constraints.
"""
try:
traffic_control.DeleteConstrainedPort(self._ports[port]['config'])
except traffic_control.TrafficControlError as e:
cherrypy.log('Error: %s\nOutput: %s' % (e.msg, e.error))
class ConstrainedNetworkServer(object):
"""A CherryPy-based HTTP server for serving files with network constraints."""
def __init__(self, options, port_allocator):
"""Sets up initial state for the CNS.
Args:
options: optparse based class returned by ParseArgs()
port_allocator: A port allocator instance.
"""
self._options = options
self._port_allocator = port_allocator
@cherrypy.expose
def Cleanup(self):
"""Cleans up all the ports allocated using the request IP address.
When requesting a constrained port, the cherrypy.request.remote.ip is used
as a key for that port (in addition to other request parameters). Such
ports created for the same IP address are removed.
"""
cherrypy.log('Cleaning up ports allocated by %s.' %
cherrypy.request.remote.ip)
self._port_allocator.Cleanup(all_ports=False,
request_ip=cherrypy.request.remote.ip)
@cherrypy.expose
def ServeConstrained(self, f=None, bandwidth=None, latency=None, loss=None,
new_port=False, no_cache=False, **kwargs):
"""Serves the requested file with the requested constraints.
Subsequent requests for the same constraints from the same IP will share the
previously created port unless new_port equals True. If no constraints
are provided the file is served as is.
Args:
f: path relative to http root of file to serve.
bandwidth: maximum allowed bandwidth for the provided port (integer
in kbit/s).
latency: time to add to each packet (integer in ms).
loss: percentage of packets to drop (integer, 0-100).
new_port: whether to use a new port for this request or not.
no_cache: Set reponse's cache-control to no-cache.
"""
if no_cache:
response = cherrypy.response
response.headers['Pragma'] = 'no-cache'
response.headers['Cache-Control'] = 'no-cache'
# CherryPy is a bit wonky at detecting parameters, so just make them all
# optional and validate them ourselves.
if not f:
raise cherrypy.HTTPError(400, 'Invalid request. File must be specified.')
# Check existence early to prevent wasted constraint setup.
self._CheckRequestedFileExist(f)
# If there are no constraints, just serve the file.
if bandwidth is None and latency is None and loss is None:
return self._ServeFile(f)
constrained_port = self._GetConstrainedPort(
f, bandwidth=bandwidth, latency=latency, loss=loss, new_port=new_port,
**kwargs)
# Build constrained URL using the constrained port and original URL
# parameters except the network constraints (bandwidth, latency, and loss).
constrained_url = self._GetServerURL(f, constrained_port,
no_cache=no_cache, **kwargs)
# Redirect request to the constrained port.
cherrypy.log('Redirect to %s' % constrained_url)
cherrypy.lib.cptools.redirect(constrained_url, internal=False)
def _CheckRequestedFileExist(self, f):
"""Checks if the requested file exists, raises HTTPError otherwise."""
if self._options.local_server_port:
self._CheckFileExistOnLocalServer(f)
else:
self._CheckFileExistOnServer(f)
def _CheckFileExistOnServer(self, f):
"""Checks if requested file f exists to be served by this server."""
# Sanitize and check the path to prevent www-root escapes.
sanitized_path = os.path.abspath(os.path.join(self._options.www_root, f))
if not sanitized_path.startswith(self._options.www_root):
raise cherrypy.HTTPError(403, 'Invalid file requested.')
if not os.path.exists(sanitized_path):
raise cherrypy.HTTPError(404, 'File not found.')
def _CheckFileExistOnLocalServer(self, f):
"""Checks if requested file exists on local server hosting files."""
test_url = self._GetServerURL(f, self._options.local_server_port)
try:
cherrypy.log('Check file exist using URL: %s' % test_url)
return urllib2.urlopen(test_url) is not None
except Exception:
raise cherrypy.HTTPError(404, 'File not found on local server.')
def _ServeFile(self, f):
"""Serves the file as an http response."""
if self._options.local_server_port:
redirect_url = self._GetServerURL(f, self._options.local_server_port)
cherrypy.log('Redirect to %s' % redirect_url)
cherrypy.lib.cptools.redirect(redirect_url, internal=False)
else:
sanitized_path = os.path.abspath(os.path.join(self._options.www_root, f))
return cherrypy.lib.static.serve_file(sanitized_path)
def _GetServerURL(self, f, port, **kwargs):
"""Returns a URL for local server to serve the file on given port.
Args:
f: file name to serve on local server. Relative to www_root.
port: Local server port (it can be a configured constrained port).
kwargs: extra parameteres passed in the URL.
"""
url = '%s?f=%s&' % (cherrypy.url(), f)
if self._options.local_server_port:
url = '%s/%s?' % (
cherrypy.url().replace('ServeConstrained', self._options.www_root), f)
url = url.replace(':%d' % self._options.port, ':%d' % port)
extra_args = urllib.urlencode(kwargs)
if extra_args:
url += extra_args
return url
def _GetConstrainedPort(self, f=None, bandwidth=None, latency=None, loss=None,
new_port=False, **kwargs):
"""Creates or gets a port with specified network constraints.
See ServeConstrained() for more details.
"""
# Validate inputs. isdigit() guarantees a natural number.
bandwidth = self._ParseIntParameter(
bandwidth, 'Invalid bandwidth constraint.', lambda x: x > 0)
latency = self._ParseIntParameter(
latency, 'Invalid latency constraint.', lambda x: x >= 0)
loss = self._ParseIntParameter(
loss, 'Invalid loss constraint.', lambda x: x <= 100 and x >= 0)
redirect_port = self._options.port
if self._options.local_server_port:
redirect_port = self._options.local_server_port
start_time = time.time()
# Allocate a port using the given constraints. If a port with the requested
# key and kwargs already exist then reuse that port.
constrained_port = self._port_allocator.Get(
cherrypy.request.remote.ip, server_port=redirect_port,
interface=self._options.interface, bandwidth=bandwidth, latency=latency,
loss=loss, new_port=new_port, file=f, **kwargs)
cherrypy.log('Time to set up port %d = %.3fsec.' %
(constrained_port, time.time() - start_time))
if not constrained_port:
raise cherrypy.HTTPError(503, 'Service unavailable. Out of ports.')
return constrained_port
def _ParseIntParameter(self, param, msg, check):
"""Returns integer value of param and verifies it satisfies the check.
Args:
param: Parameter name to check.
msg: Message in error if raised.
check: Check to verify the parameter value.
Returns:
None if param is None, integer value of param otherwise.
Raises:
cherrypy.HTTPError if param can not be converted to integer or if it does
not satisfy the check.
"""
if param:
try:
int_value = int(param)
if check(int_value):
return int_value
except:
pass
raise cherrypy.HTTPError(400, msg)
def ParseArgs():
"""Define and parse the command-line arguments."""
parser = optparse.OptionParser()
parser.add_option('--expiry-time', type='int',
default=_DEFAULT_PORT_EXPIRY_TIME_SECS,
help=('Number of seconds before constrained ports expire '
'and are cleaned up. 0=Disabled. Default: %default'))
parser.add_option('--port', type='int', default=_DEFAULT_SERVING_PORT,
help='Port to serve the API on. Default: %default')
parser.add_option('--port-range', default=_DEFAULT_CNS_PORT_RANGE,
help=('Range of ports for constrained serving. Specify as '
'a comma separated value pair. Default: %default'))
parser.add_option('--interface', default='eth0',
help=('Interface to setup constraints on. Use lo for a '
'local client. Default: %default'))
parser.add_option('--socket-timeout', type='int',
default=cherrypy.server.socket_timeout,
help=('Number of seconds before a socket connection times '
'out. Default: %default'))
parser.add_option('--threads', type='int',
default=cherrypy._cpserver.Server.thread_pool,
help=('Number of threads in the thread pool. Default: '
'%default'))
parser.add_option('--www-root', default='',
help=('Directory root to serve files from. If --local-'
'server-port is used, the path is appended to the '
'redirected URL of local server. Defaults to the '
'current directory (if --local-server-port is not '
'used): %s' % os.getcwd()))
parser.add_option('--local-server-port', type='int',
help=('Optional local server port to host files.'))
parser.add_option('-v', '--verbose', action='store_true', default=False,
help='Turn on verbose output.')
options = parser.parse_args()[0]
# Convert port range into the desired tuple format.
try:
if isinstance(options.port_range, str):
options.port_range = [int(port) for port in options.port_range.split(',')]
except ValueError:
parser.error('Invalid port range specified.')
if options.expiry_time < 0:
parser.error('Invalid expiry time specified.')
# Convert the path to an absolute to remove any . or ..
if not options.local_server_port:
if not options.www_root:
options.www_root = os.getcwd()
options.www_root = os.path.abspath(options.www_root)
_SetLogger(options.verbose)
return options
def _SetLogger(verbose):
file_handler = handlers.RotatingFileHandler('cns.log', 'a', 10000000, 10)
file_handler.setFormatter(logging.Formatter('[%(threadName)s] %(message)s'))
log_level = _DEFAULT_LOG_LEVEL
if verbose:
log_level = logging.DEBUG
file_handler.setLevel(log_level)
cherrypy.log.error_log.addHandler(file_handler)
cherrypy.log.access_log.addHandler(file_handler)
def Main():
"""Configure and start the ConstrainedNetworkServer."""
options = ParseArgs()
try:
traffic_control.CheckRequirements()
except traffic_control.TrafficControlError as e:
cherrypy.log(e.msg)
return
cherrypy.config.update({'server.socket_host': '::',
'server.socket_port': options.port})
if options.threads:
cherrypy.config.update({'server.thread_pool': options.threads})
if options.socket_timeout:
cherrypy.config.update({'server.socket_timeout': options.socket_timeout})
# Setup port allocator here so we can call cleanup on failures/exit.
pa = PortAllocator(options.port_range, expiry_time_secs=options.expiry_time)
try:
cherrypy.quickstart(ConstrainedNetworkServer(options, pa))
finally:
# Disable Ctrl-C handler to prevent interruption of cleanup.
signal.signal(signal.SIGINT, lambda signal, frame: None)
pa.Cleanup(all_ports=True)
if __name__ == '__main__':
Main()