Home | History | Annotate | Download | only in constrained_network_server
      1 # Copyright (c) 2012 The Chromium Authors. All rights reserved.
      2 # Use of this source code is governed by a BSD-style license that can be
      3 # found in the LICENSE file.
      4 
      5 """Traffic control library for constraining the network configuration on a port.
      6 
      7 The traffic controller sets up a constrained network configuration on a port.
      8 Traffic to the constrained port is forwarded to a specified server port.
      9 """
     10 
     11 import logging
     12 import os
     13 import re
     14 import subprocess
     15 
     16 # The maximum bandwidth limit.
     17 _DEFAULT_MAX_BANDWIDTH_KBIT = 1000000
     18 
     19 
     20 class TrafficControlError(BaseException):
     21   """Exception raised for errors in traffic control library.
     22 
     23   Attributes:
     24     msg: User defined error message.
     25     cmd: Command for which the exception was raised.
     26     returncode: Return code of running the command.
     27     stdout: Output of running the command.
     28     stderr: Error output of running the command.
     29   """
     30 
     31   def __init__(self, msg, cmd=None, returncode=None, output=None,
     32                error=None):
     33     BaseException.__init__(self, msg)
     34     self.msg = msg
     35     self.cmd = cmd
     36     self.returncode = returncode
     37     self.output = output
     38     self.error = error
     39 
     40 
     41 def CheckRequirements():
     42   """Checks if permissions are available to run traffic control commands.
     43 
     44   Raises:
     45     TrafficControlError: If permissions to run traffic control commands are not
     46     available.
     47   """
     48   if os.geteuid() != 0:
     49     _Exec(['sudo', '-n', 'tc', '-help'],
     50           msg=('Cannot run \'tc\' command. Traffic Control must be run as root '
     51                'or have password-less sudo access to this command.'))
     52     _Exec(['sudo', '-n', 'iptables', '-help'],
     53           msg=('Cannot run \'iptables\' command. Traffic Control must be run '
     54                'as root or have password-less sudo access to this command.'))
     55 
     56 
     57 def CreateConstrainedPort(config):
     58   """Creates a new constrained port.
     59 
     60   Imposes packet level constraints such as bandwidth, latency, and packet loss
     61   on a given port using the specified configuration dictionary. Traffic to that
     62   port is forwarded to a specified server port.
     63 
     64   Args:
     65     config: Constraint configuration dictionary, format:
     66       port: Port to constrain (integer 1-65535).
     67       server_port: Port to redirect traffic on [port] to (integer 1-65535).
     68       interface: Network interface name (string).
     69       latency: Delay added on each packet sent (integer in ms).
     70       bandwidth: Maximum allowed upload bandwidth (integer in kbit/s).
     71       loss: Percentage of packets to drop (integer 0-100).
     72 
     73   Raises:
     74     TrafficControlError: If any operation fails. The message in the exception
     75     describes what failed.
     76   """
     77   _CheckArgsExist(config, 'interface', 'port', 'server_port')
     78   _AddRootQdisc(config['interface'])
     79 
     80   try:
     81     _ConfigureClass('add', config)
     82     _AddSubQdisc(config)
     83     _AddFilter(config['interface'], config['port'])
     84     _AddIptableRule(config['interface'], config['port'], config['server_port'])
     85   except TrafficControlError as e:
     86     logging.debug('Error creating constrained port %d.\nError: %s\n'
     87                   'Deleting constrained port.', config['port'], e.error)
     88     DeleteConstrainedPort(config)
     89     raise e
     90 
     91 
     92 def DeleteConstrainedPort(config):
     93   """Deletes an existing constrained port.
     94 
     95   Deletes constraints set on a given port and the traffic forwarding rule from
     96   the constrained port to a specified server port.
     97 
     98   The original constrained network configuration used to create the constrained
     99   port must be passed in.
    100 
    101   Args:
    102     config: Constraint configuration dictionary, format:
    103       port: Port to constrain (integer 1-65535).
    104       server_port: Port to redirect traffic on [port] to (integer 1-65535).
    105       interface: Network interface name (string).
    106       bandwidth: Maximum allowed upload bandwidth (integer in kbit/s).
    107 
    108   Raises:
    109     TrafficControlError: If any operation fails. The message in the exception
    110     describes what failed.
    111   """
    112   _CheckArgsExist(config, 'interface', 'port', 'server_port')
    113   try:
    114     # Delete filters first so it frees the class.
    115     _DeleteFilter(config['interface'], config['port'])
    116   finally:
    117     try:
    118       # Deleting the class deletes attached qdisc as well.
    119       _ConfigureClass('del', config)
    120     finally:
    121       _DeleteIptableRule(config['interface'], config['port'],
    122                          config['server_port'])
    123 
    124 
    125 def TearDown(config):
    126   """Deletes the root qdisc and all iptables rules.
    127 
    128   Args:
    129     config: Constraint configuration dictionary, format:
    130       interface: Network interface name (string).
    131 
    132   Raises:
    133     TrafficControlError: If any operation fails. The message in the exception
    134     describes what failed.
    135   """
    136   _CheckArgsExist(config, 'interface')
    137 
    138   command = ['sudo', 'tc', 'qdisc', 'del', 'dev', config['interface'], 'root']
    139   try:
    140     _Exec(command, msg='Could not delete root qdisc.')
    141   finally:
    142     _DeleteAllIpTableRules()
    143 
    144 
    145 def _CheckArgsExist(config, *args):
    146   """Check that the args exist in config dictionary and are not None.
    147 
    148   Args:
    149     config: Any dictionary.
    150     *args: The list of key names to check.
    151 
    152   Raises:
    153     TrafficControlError: If any key name does not exist in config or is None.
    154   """
    155   for key in args:
    156     if key not in config.keys() or config[key] is None:
    157       raise TrafficControlError('Missing "%s" parameter.' % key)
    158 
    159 
    160 def _AddRootQdisc(interface):
    161   """Sets up the default root qdisc.
    162 
    163   Args:
    164     interface: Network interface name.
    165 
    166   Raises:
    167     TrafficControlError: If adding the root qdisc fails for a reason other than
    168     it already exists.
    169   """
    170   command = ['sudo', 'tc', 'qdisc', 'add', 'dev', interface, 'root', 'handle',
    171              '1:', 'htb']
    172   try:
    173     _Exec(command, msg=('Error creating root qdisc. '
    174                         'Make sure you have root access'))
    175   except TrafficControlError as e:
    176     # Ignore the error if root already exists.
    177     if not 'File exists' in e.error:
    178       raise e
    179 
    180 
    181 def _ConfigureClass(option, config):
    182   """Adds or deletes a class and qdisc attached to the root.
    183 
    184   The class specifies bandwidth, and qdisc specifies delay and packet loss. The
    185   class ID is based on the config port.
    186 
    187   Args:
    188     option: Adds or deletes a class option [add|del].
    189     config: Constraint configuration dictionary, format:
    190       port: Port to constrain (integer 1-65535).
    191       interface: Network interface name (string).
    192       bandwidth: Maximum allowed upload bandwidth (integer in kbit/s).
    193   """
    194   # Use constrained port as class ID so we can attach the qdisc and filter to
    195   # it, as well as delete the class, using only the port number.
    196   class_id = '1:%x' % config['port']
    197   if 'bandwidth' not in config.keys() or not config['bandwidth']:
    198     bandwidth = _DEFAULT_MAX_BANDWIDTH_KBIT
    199   else:
    200     bandwidth = config['bandwidth']
    201 
    202   bandwidth = '%dkbit' % bandwidth
    203   command = ['sudo', 'tc', 'class', option, 'dev', config['interface'],
    204              'parent', '1:', 'classid', class_id, 'htb', 'rate', bandwidth,
    205              'ceil', bandwidth]
    206   _Exec(command, msg=('Error configuring class ID %s using "%s" command.' %
    207                       (class_id, option)))
    208 
    209 
    210 def _AddSubQdisc(config):
    211   """Adds a qdisc attached to the class identified by the config port.
    212 
    213   Args:
    214     config: Constraint configuration dictionary, format:
    215       port: Port to constrain (integer 1-65535).
    216       interface: Network interface name (string).
    217       latency: Delay added on each packet sent (integer in ms).
    218       loss: Percentage of packets to drop (integer 0-100).
    219   """
    220   port_hex = '%x' % config['port']
    221   class_id = '1:%x' % config['port']
    222   command = ['sudo', 'tc', 'qdisc', 'add', 'dev', config['interface'], 'parent',
    223              class_id, 'handle', port_hex + ':0', 'netem']
    224 
    225   # Check if packet-loss is set in the configuration.
    226   if 'loss' in config.keys() and config['loss']:
    227     loss = '%d%%' % config['loss']
    228     command.extend(['loss', loss])
    229   # Check if latency is set in the configuration.
    230   if 'latency' in config.keys() and config['latency']:
    231     latency = '%dms' % config['latency']
    232     command.extend(['delay', latency])
    233 
    234   _Exec(command, msg='Could not attach qdisc to class ID %s.' % class_id)
    235 
    236 
    237 def _AddFilter(interface, port):
    238   """Redirects packets coming to a specified port into the constrained class.
    239 
    240   Args:
    241     interface: Interface name to attach the filter to (string).
    242     port: Port number to filter packets with (integer 1-65535).
    243   """
    244   class_id = '1:%x' % port
    245 
    246   command = ['sudo', 'tc', 'filter', 'add', 'dev', interface, 'protocol', 'ip',
    247              'parent', '1:', 'prio', '1', 'u32', 'match', 'ip', 'sport', port,
    248              '0xffff', 'flowid', class_id]
    249   _Exec(command, msg='Error adding filter on port %d.' % port)
    250 
    251 
    252 def _DeleteFilter(interface, port):
    253   """Deletes the filter attached to the configured port.
    254 
    255   Args:
    256     interface: Interface name the filter is attached to (string).
    257     port: Port number being filtered (integer 1-65535).
    258   """
    259   handle_id = _GetFilterHandleId(interface, port)
    260   command = ['sudo', 'tc', 'filter', 'del', 'dev', interface, 'protocol', 'ip',
    261              'parent', '1:0', 'handle', handle_id, 'prio', '1', 'u32']
    262   _Exec(command, msg='Error deleting filter on port %d.' % port)
    263 
    264 
    265 def _GetFilterHandleId(interface, port):
    266   """Searches for the handle ID of the filter identified by the config port.
    267 
    268   Args:
    269     interface: Interface name the filter is attached to (string).
    270     port: Port number being filtered (integer 1-65535).
    271 
    272   Returns:
    273     The handle ID.
    274 
    275   Raises:
    276     TrafficControlError: If handle ID was not found.
    277   """
    278   command = ['sudo', 'tc', 'filter', 'list', 'dev', interface, 'parent', '1:']
    279   output = _Exec(command, msg='Error listing filters.')
    280   # Search for the filter handle ID associated with class ID '1:port'.
    281   handle_id_re = re.search(
    282       '([0-9a-fA-F]{3}::[0-9a-fA-F]{3}).*(?=flowid 1:%x\s)' % port, output)
    283   if handle_id_re:
    284     return handle_id_re.group(1)
    285   raise TrafficControlError(('Could not find filter handle ID for class ID '
    286                              '1:%x.') % port)
    287 
    288 
    289 def _AddIptableRule(interface, port, server_port):
    290   """Forwards traffic from constrained port to a specified server port.
    291 
    292   Args:
    293     interface: Interface name to attach the filter to (string).
    294     port: Port of incoming packets (integer 1-65535).
    295     server_port: Server port to forward the packets to (integer 1-65535).
    296   """
    297   # Preroute rules for accessing the port through external connections.
    298   command = ['sudo', 'iptables', '-t', 'nat', '-A', 'PREROUTING', '-i',
    299              interface, '-p', 'tcp', '--dport', port, '-j', 'REDIRECT',
    300              '--to-port', server_port]
    301   _Exec(command, msg='Error adding iptables rule for port %d.' % port)
    302 
    303   # Output rules for accessing the rule through localhost or 127.0.0.1
    304   command = ['sudo', 'iptables', '-t', 'nat', '-A', 'OUTPUT', '-p', 'tcp',
    305              '--dport', port, '-j', 'REDIRECT', '--to-port', server_port]
    306   _Exec(command, msg='Error adding iptables rule for port %d.' % port)
    307 
    308 
    309 def _DeleteIptableRule(interface, port, server_port):
    310   """Deletes the iptable rule associated with specified port number.
    311 
    312   Args:
    313     interface: Interface name to attach the filter to (string).
    314     port: Port of incoming packets (integer 1-65535).
    315     server_port: Server port packets are forwarded to (integer 1-65535).
    316   """
    317   command = ['sudo', 'iptables', '-t', 'nat', '-D', 'PREROUTING', '-i',
    318              interface, '-p', 'tcp', '--dport', port, '-j', 'REDIRECT',
    319              '--to-port', server_port]
    320   _Exec(command, msg='Error deleting iptables rule for port %d.' % port)
    321 
    322   command = ['sudo', 'iptables', '-t', 'nat', '-D', 'OUTPUT', '-p', 'tcp',
    323              '--dport', port, '-j', 'REDIRECT', '--to-port', server_port]
    324   _Exec(command, msg='Error adding iptables rule for port %d.' % port)
    325 
    326 
    327 def _DeleteAllIpTableRules():
    328   """Deletes all iptables rules."""
    329   command = ['sudo', 'iptables', '-t', 'nat', '-F']
    330   _Exec(command, msg='Error deleting all iptables rules.')
    331 
    332 
    333 def _Exec(command, msg=None):
    334   """Executes a command.
    335 
    336   Args:
    337     command: Command list to execute.
    338     msg: Message describing the error in case the command fails.
    339 
    340   Returns:
    341     The standard output from running the command.
    342 
    343   Raises:
    344     TrafficControlError: If command fails. Message is set by the msg parameter.
    345   """
    346   cmd_list = [str(x) for x in command]
    347   cmd = ' '.join(cmd_list)
    348   logging.debug('Running command: %s', cmd)
    349 
    350   p = subprocess.Popen(cmd_list, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    351   output, error = p.communicate()
    352   if p.returncode != 0:
    353     raise TrafficControlError(msg, cmd, p.returncode, output, error)
    354   return output.strip()
    355