summaryrefslogtreecommitdiffstats
path: root/media/tools/constrained_network_server/cns.py
blob: 58e2ba000d8cce73cb3a48bff7247e77dcfd17b7 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
#!/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()