# 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. """Traffic control library for constraining the network configuration on a port. The traffic controller sets up a constrained network configuration on a port. Traffic to the constrained port is forwarded to a specified server port. """ import logging import os import re import subprocess # The maximum bandwidth limit. _DEFAULT_MAX_BANDWIDTH_KBIT = 1000000 class TrafficControlError(BaseException): """Exception raised for errors in traffic control library. Attributes: msg: User defined error message. cmd: Command for which the exception was raised. returncode: Return code of running the command. stdout: Output of running the command. stderr: Error output of running the command. """ def __init__(self, msg, cmd=None, returncode=None, output=None, error=None): BaseException.__init__(self, msg) self.msg = msg self.cmd = cmd self.returncode = returncode self.output = output self.error = error def CheckRequirements(): """Checks if permissions are available to run traffic control commands. Raises: TrafficControlError: If permissions to run traffic control commands are not available. """ if os.geteuid() != 0: _Exec(['sudo', '-n', 'tc', '-help'], msg=('Cannot run \'tc\' command. Traffic Control must be run as root ' 'or have password-less sudo access to this command.')) _Exec(['sudo', '-n', 'iptables', '-help'], msg=('Cannot run \'iptables\' command. Traffic Control must be run ' 'as root or have password-less sudo access to this command.')) def CreateConstrainedPort(config): """Creates a new constrained port. Imposes packet level constraints such as bandwidth, latency, and packet loss on a given port using the specified configuration dictionary. Traffic to that port is forwarded to a specified server port. Args: config: Constraint configuration dictionary, format: port: Port to constrain (integer 1-65535). server_port: Port to redirect traffic on [port] to (integer 1-65535). interface: Network interface name (string). latency: Delay added on each packet sent (integer in ms). bandwidth: Maximum allowed upload bandwidth (integer in kbit/s). loss: Percentage of packets to drop (integer 0-100). Raises: TrafficControlError: If any operation fails. The message in the exception describes what failed. """ _CheckArgsExist(config, 'interface', 'port', 'server_port') _AddRootQdisc(config['interface']) try: _ConfigureClass('add', config) _AddSubQdisc(config) _AddFilter(config['interface'], config['port']) _AddIptableRule(config['interface'], config['port'], config['server_port']) except TrafficControlError as e: logging.debug('Error creating constrained port %d.\nError: %s\n' 'Deleting constrained port.', config['port'], e.error) DeleteConstrainedPort(config) raise e def DeleteConstrainedPort(config): """Deletes an existing constrained port. Deletes constraints set on a given port and the traffic forwarding rule from the constrained port to a specified server port. The original constrained network configuration used to create the constrained port must be passed in. Args: config: Constraint configuration dictionary, format: port: Port to constrain (integer 1-65535). server_port: Port to redirect traffic on [port] to (integer 1-65535). interface: Network interface name (string). bandwidth: Maximum allowed upload bandwidth (integer in kbit/s). Raises: TrafficControlError: If any operation fails. The message in the exception describes what failed. """ _CheckArgsExist(config, 'interface', 'port', 'server_port') try: # Delete filters first so it frees the class. _DeleteFilter(config['interface'], config['port']) finally: try: # Deleting the class deletes attached qdisc as well. _ConfigureClass('del', config) finally: _DeleteIptableRule(config['interface'], config['port'], config['server_port']) def TearDown(config): """Deletes the root qdisc and all iptables rules. Args: config: Constraint configuration dictionary, format: interface: Network interface name (string). Raises: TrafficControlError: If any operation fails. The message in the exception describes what failed. """ _CheckArgsExist(config, 'interface') command = ['sudo', 'tc', 'qdisc', 'del', 'dev', config['interface'], 'root'] try: _Exec(command, msg='Could not delete root qdisc.') finally: _DeleteAllIpTableRules() def _CheckArgsExist(config, *args): """Check that the args exist in config dictionary and are not None. Args: config: Any dictionary. *args: The list of key names to check. Raises: TrafficControlError: If any key name does not exist in config or is None. """ for key in args: if key not in config.keys() or config[key] is None: raise TrafficControlError('Missing "%s" parameter.' % key) def _AddRootQdisc(interface): """Sets up the default root qdisc. Args: interface: Network interface name. Raises: TrafficControlError: If adding the root qdisc fails for a reason other than it already exists. """ command = ['sudo', 'tc', 'qdisc', 'add', 'dev', interface, 'root', 'handle', '1:', 'htb'] try: _Exec(command, msg=('Error creating root qdisc. ' 'Make sure you have root access')) except TrafficControlError as e: # Ignore the error if root already exists. if not 'File exists' in e.error: raise e def _ConfigureClass(option, config): """Adds or deletes a class and qdisc attached to the root. The class specifies bandwidth, and qdisc specifies delay and packet loss. The class ID is based on the config port. Args: option: Adds or deletes a class option [add|del]. config: Constraint configuration dictionary, format: port: Port to constrain (integer 1-65535). interface: Network interface name (string). bandwidth: Maximum allowed upload bandwidth (integer in kbit/s). """ # Use constrained port as class ID so we can attach the qdisc and filter to # it, as well as delete the class, using only the port number. class_id = '1:%x' % config['port'] if 'bandwidth' not in config.keys() or not config['bandwidth']: bandwidth = _DEFAULT_MAX_BANDWIDTH_KBIT else: bandwidth = config['bandwidth'] bandwidth = '%dkbit' % bandwidth command = ['sudo', 'tc', 'class', option, 'dev', config['interface'], 'parent', '1:', 'classid', class_id, 'htb', 'rate', bandwidth, 'ceil', bandwidth] _Exec(command, msg=('Error configuring class ID %s using "%s" command.' % (class_id, option))) def _AddSubQdisc(config): """Adds a qdisc attached to the class identified by the config port. Args: config: Constraint configuration dictionary, format: port: Port to constrain (integer 1-65535). interface: Network interface name (string). latency: Delay added on each packet sent (integer in ms). loss: Percentage of packets to drop (integer 0-100). """ port_hex = '%x' % config['port'] class_id = '1:%x' % config['port'] command = ['sudo', 'tc', 'qdisc', 'add', 'dev', config['interface'], 'parent', class_id, 'handle', port_hex + ':0', 'netem'] # Check if packet-loss is set in the configuration. if 'loss' in config.keys() and config['loss']: loss = '%d%%' % config['loss'] command.extend(['loss', loss]) # Check if latency is set in the configuration. if 'latency' in config.keys() and config['latency']: latency = '%dms' % config['latency'] command.extend(['delay', latency]) _Exec(command, msg='Could not attach qdisc to class ID %s.' % class_id) def _AddFilter(interface, port): """Redirects packets coming to a specified port into the constrained class. Args: interface: Interface name to attach the filter to (string). port: Port number to filter packets with (integer 1-65535). """ class_id = '1:%x' % port command = ['sudo', 'tc', 'filter', 'add', 'dev', interface, 'protocol', 'ip', 'parent', '1:', 'prio', '1', 'u32', 'match', 'ip', 'sport', port, '0xffff', 'flowid', class_id] _Exec(command, msg='Error adding filter on port %d.' % port) def _DeleteFilter(interface, port): """Deletes the filter attached to the configured port. Args: interface: Interface name the filter is attached to (string). port: Port number being filtered (integer 1-65535). """ handle_id = _GetFilterHandleId(interface, port) command = ['sudo', 'tc', 'filter', 'del', 'dev', interface, 'protocol', 'ip', 'parent', '1:0', 'handle', handle_id, 'prio', '1', 'u32'] _Exec(command, msg='Error deleting filter on port %d.' % port) def _GetFilterHandleId(interface, port): """Searches for the handle ID of the filter identified by the config port. Args: interface: Interface name the filter is attached to (string). port: Port number being filtered (integer 1-65535). Returns: The handle ID. Raises: TrafficControlError: If handle ID was not found. """ command = ['sudo', 'tc', 'filter', 'list', 'dev', interface, 'parent', '1:'] output = _Exec(command, msg='Error listing filters.') # Search for the filter handle ID associated with class ID '1:port'. handle_id_re = re.search( '([0-9a-fA-F]{3}::[0-9a-fA-F]{3}).*(?=flowid 1:%x\s)' % port, output) if handle_id_re: return handle_id_re.group(1) raise TrafficControlError(('Could not find filter handle ID for class ID ' '1:%x.') % port) def _AddIptableRule(interface, port, server_port): """Forwards traffic from constrained port to a specified server port. Args: interface: Interface name to attach the filter to (string). port: Port of incoming packets (integer 1-65535). server_port: Server port to forward the packets to (integer 1-65535). """ # Preroute rules for accessing the port through external connections. command = ['sudo', 'iptables', '-t', 'nat', '-A', 'PREROUTING', '-i', interface, '-p', 'tcp', '--dport', port, '-j', 'REDIRECT', '--to-port', server_port] _Exec(command, msg='Error adding iptables rule for port %d.' % port) # Output rules for accessing the rule through localhost or 127.0.0.1 command = ['sudo', 'iptables', '-t', 'nat', '-A', 'OUTPUT', '-p', 'tcp', '--dport', port, '-j', 'REDIRECT', '--to-port', server_port] _Exec(command, msg='Error adding iptables rule for port %d.' % port) def _DeleteIptableRule(interface, port, server_port): """Deletes the iptable rule associated with specified port number. Args: interface: Interface name to attach the filter to (string). port: Port of incoming packets (integer 1-65535). server_port: Server port packets are forwarded to (integer 1-65535). """ command = ['sudo', 'iptables', '-t', 'nat', '-D', 'PREROUTING', '-i', interface, '-p', 'tcp', '--dport', port, '-j', 'REDIRECT', '--to-port', server_port] _Exec(command, msg='Error deleting iptables rule for port %d.' % port) command = ['sudo', 'iptables', '-t', 'nat', '-D', 'OUTPUT', '-p', 'tcp', '--dport', port, '-j', 'REDIRECT', '--to-port', server_port] _Exec(command, msg='Error adding iptables rule for port %d.' % port) def _DeleteAllIpTableRules(): """Deletes all iptables rules.""" command = ['sudo', 'iptables', '-t', 'nat', '-F'] _Exec(command, msg='Error deleting all iptables rules.') def _Exec(command, msg=None): """Executes a command. Args: command: Command list to execute. msg: Message describing the error in case the command fails. Returns: The standard output from running the command. Raises: TrafficControlError: If command fails. Message is set by the msg parameter. """ cmd_list = [str(x) for x in command] cmd = ' '.join(cmd_list) logging.debug('Running command: %s', cmd) p = subprocess.Popen(cmd_list, stdout=subprocess.PIPE, stderr=subprocess.PIPE) output, error = p.communicate() if p.returncode != 0: raise TrafficControlError(msg, cmd, p.returncode, output, error) return output.strip()