| #!/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() |