diff --git a/tools/network_simulator/config.py b/tools/network_simulator/config.py new file mode 100644 index 0000000000..ac450f6b96 --- /dev/null +++ b/tools/network_simulator/config.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python +# Copyright (c) 2012 The WebRTC project authors. All Rights Reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE file in the root of the source +# tree. An additional intellectual property rights grant can be found +# in the file PATENTS. All contributing project authors may +# be found in the AUTHORS file in the root of the source tree. + +"""Configuration class for network simulation.""" + + +class ConnectionConfig(object): + """Configuration containing the characteristics of a network connection.""" + + def __init__(self, num, name, receive_bw_kbps, send_bw_kbps, delay_ms, + packet_loss_percent, queue_slots): + self.num = num + self.name = name + self.receive_bw_kbps = receive_bw_kbps + self.send_bw_kbps = send_bw_kbps + self.delay_ms = delay_ms + self.packet_loss_percent = packet_loss_percent + self.queue_slots = queue_slots + + def __str__(self): + """String representing the configuration. + + Returns: + A string formatted and padded like this example: + 12 Name 375 kbps 375 kbps 10 145 ms 0.1 % + """ + left_aligned_name = self.name.ljust(24, ' ') + return '%2s %24s %5s kbps %5s kbps %4s %5s ms %3s %%' % ( + self.num, left_aligned_name, self.receive_bw_kbps, self.send_bw_kbps, + self.queue_slots, self.delay_ms, self.packet_loss_percent) diff --git a/tools/network_simulator/network_simulator.py b/tools/network_simulator/network_simulator.py new file mode 100644 index 0000000000..3518999c21 --- /dev/null +++ b/tools/network_simulator/network_simulator.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python +# Copyright (c) 2012 The WebRTC project authors. All Rights Reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE file in the root of the source +# tree. An additional intellectual property rights grant can be found +# in the file PATENTS. All contributing project authors may +# be found in the AUTHORS file in the root of the source tree. + +"""Script for constraining traffic on the local machine.""" + +import logging +import os +import subprocess + + +class NetworkSimulatorError(BaseException): + """Exception raised for errors in the network simulator. + + 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 + + +class NetworkSimulator(object): + """A network simulator that can constrain the network using Dummynet.""" + + def __init__(self, connection_config, port_range): + """Constructor. + + Args: + connection_config: A config.ConnectionConfig object containing the + characteristics for the connection to be simulated. + port_range: Tuple containing two integers defining the port range. + """ + self._pipe_counter = 0 + self._rule_counter = 0 + self._port_range = port_range + self._connection_config = connection_config + + def simulate(self, target_ip): + """Starts a network simulation by setting up Dummynet rules. + + Args: + target_ip: The IP address of the interface that shall be that have the + network constraints applied to it. + """ + receive_pipe_id = self._create_dummynet_pipe( + self._connection_config.receive_bw_kbps, + self._connection_config.delay_ms, + self._connection_config.packet_loss_percent, + self._connection_config.queue_slots) + logging.debug('Created receive pipe: %s', receive_pipe_id) + send_pipe_id = self._create_dummynet_pipe( + self._connection_config.send_bw_kbps, + self._connection_config.delay_ms, + self._connection_config.packet_loss_percent, + self._connection_config.queue_slots) + logging.debug('Created send pipe: %s', send_pipe_id) + + # Adding the rules will start the simulation. + incoming_rule_id = self._create_dummynet_rule(receive_pipe_id, 'any', + target_ip, self._port_range) + logging.debug('Created incoming rule: %s', incoming_rule_id) + outgoing_rule_id = self._create_dummynet_rule(send_pipe_id, target_ip, + 'any', self._port_range) + logging.debug('Created outgoing rule: %s', outgoing_rule_id) + + def check_permissions(self): + """Checks if permissions are available to run Dummynet commands. + + Raises: + NetworkSimulatorError: If permissions to run Dummynet commands are not + available. + """ + if os.geteuid() != 0: + self._run_shell_command( + ['sudo', '-n', 'ipfw', '-h'], + msg=('Cannot run \'ipfw\' command. This script must be run as ' + 'root or have password-less sudo access to this command.')) + + def cleanup(self): + """Stops the network simulation by flushing all Dummynet rules. + + Notice that this will flush any rules that may have been created previously + before starting the simulation. + """ + self._run_shell_command(['sudo', 'ipfw', '-f', 'flush'], + 'Failed to flush Dummynet rules!') + + def _create_dummynet_rule(self, pipe_id, from_address, to_address, + port_range): + """Creates a network simulation rule and returns its ID. + + Args: + pipe_id: integer ID of the pipe. + from_address: The IP address to match source address. May be an IP or + 'any'. + to_address: The IP address to match destination address. May be an IP or + 'any'. + port_range: The range of ports the rule shall be applied on. Must be + specified as a tuple of with two integers. + Returns: + The ID of the rule, starting at 100. The rule ID increments with 100 for + each rule being added. + """ + self._rule_counter += 100 + add_part = ['sudo', 'ipfw', 'add', self._rule_counter, 'pipe', pipe_id, + 'ip', 'from', from_address, 'to', to_address] + self._run_shell_command(add_part + ['src-port', '%s-%s' % port_range], + 'Failed to add Dummynet src-port rule.') + self._run_shell_command(add_part + ['dst-port', '%s-%s' % port_range], + 'Failed to add Dummynet dst-port rule.') + return self._rule_counter + + def _create_dummynet_pipe(self, bandwidth_kbps, delay_ms, packet_loss_percent, + queue_slots): + """Creates a Dummynet pipe and return its ID. + + Args: + bandwidth_kbps: Bandwidth. + delay_ms: Delay for a one-way trip of a packet. + packet_loss_percent: Float value of packet loss, in percent. + queue_slots: Size of the queue. + Returns: + The ID of the pipe, starting at 1. + """ + self._pipe_counter += 1 + cmd = ['sudo', 'ipfw', 'pipe', self._pipe_counter, 'config', + 'bw', str(bandwidth_kbps/8) + 'KByte/s', + 'delay', '%sms' % delay_ms, + 'plr', (packet_loss_percent/100.0), + 'queue', queue_slots] + self._run_shell_command(cmd, 'Failed to create Dummynet pipe') + return self._pipe_counter + + def _run_shell_command(self, 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: + NetworkSimulatorError: 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) + + process = subprocess.Popen(cmd_list, stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + output, error = process.communicate() + if process.returncode != 0: + raise NetworkSimulatorError(msg, cmd, process.returncode, output, error) + return output.strip() diff --git a/tools/network_simulator/simulate.py b/tools/network_simulator/simulate.py new file mode 100755 index 0000000000..b1b3e67a61 --- /dev/null +++ b/tools/network_simulator/simulate.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python +# Copyright (c) 2012 The WebRTC project authors. All Rights Reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE file in the root of the source +# tree. An additional intellectual property rights grant can be found +# in the file PATENTS. All contributing project authors may +# be found in the AUTHORS file in the root of the source tree. + +"""Script for constraining traffic on the local machine.""" + +import logging +import optparse +import os +import socket +import sys + +import config +import network_simulator + +_DEFAULT_LOG_LEVEL = logging.INFO + +# Default port range to apply network constraints on. +_DEFAULT_PORT_RANGE = (30000, 65535) + +_PRESETS = [ + config.ConnectionConfig(1, 'Generic, Bad', 95, 95, 250, 2, 100), + config.ConnectionConfig(2, 'Generic, Average', 375, 375, 145, 0.1, 100), + config.ConnectionConfig(3, 'Generic, Good', 1000, 1000, 35, 0, 100), + config.ConnectionConfig(4, '3G, Average Case', 780, 330, 100, 0, 100), + config.ConnectionConfig(5, '3G, Good', 850, 420, 90, 0, 100), + config.ConnectionConfig(6, '3G, Lossy Network', 780, 330, 100, 1, 100), + config.ConnectionConfig(7, 'Cable Modem', 6000, 1000, 2, 0, 10), + config.ConnectionConfig(8, 'DSL', 2000, 256, 5, 0, 10), + config.ConnectionConfig(9, 'Edge, Average Case', 240, 200, 400, 0, 100), + config.ConnectionConfig(10, 'Edge, Good', 250, 200, 350, 0, 100), + config.ConnectionConfig(11, 'Edge, Lossy Network', 240, 200, 400, 1, 100), + config.ConnectionConfig(12, 'Wifi, Average Case', 40000, 33000, 1, 0, 100), + config.ConnectionConfig(13, 'Wifi, Good', 45000, 40000, 1, 0, 100), + config.ConnectionConfig(14, 'Wifi, Lossy', 40000, 33000, 1, 0, 100), + ] +_PRESETS_DICT = dict((p.num, p) for p in _PRESETS) + +_DEFAULT_PRESET_ID = 2 +_DEFAULT_PRESET = _PRESETS_DICT[_DEFAULT_PRESET_ID] + + +class NonStrippingEpilogOptionParser(optparse.OptionParser): + """Custom parser to let us show the epilog without weird line breaking.""" + + def format_epilog(self, formatter): + return self.epilog + + +def _get_external_ip(): + """Finds out the machine's external IP by connecting to google.com.""" + external_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + external_socket.connect(('google.com', 80)) + return external_socket.getsockname()[0] + + +def _parse_args(): + """Define and parse the command-line arguments.""" + presets_string = '\n'.join(str(p) for p in _PRESETS) + parser = NonStrippingEpilogOptionParser(epilog=( + '\nAvailable presets:\n' + ' Bandwidth (kbps) Packet\n' + 'ID Name Receive Send Queue Delay loss \n' + '-- ---- --------- -------- ----- ------- ------\n' + '%s\n' % presets_string)) + parser.add_option('-p', '--preset', type='int', default=2, + help=('ConnectionConfig configuration, specified by ID. ' + 'Default: %default')) + parser.add_option('-r', '--receive-bw', type='int', + default=_DEFAULT_PRESET.receive_bw_kbps, + help=('Receive bandwidth in kilobit/s. Default: %default')) + parser.add_option('-s', '--send-bw', type='int', + default=_DEFAULT_PRESET.send_bw_kbps, + help=('Send bandwidth in kilobit/s. Default: %default')) + parser.add_option('-d', '--delay', type='int', + default=_DEFAULT_PRESET.delay_ms, + help=('Delay in ms. Default: %default')) + parser.add_option('-l', '--packet-loss', type='float', + default=_DEFAULT_PRESET.packet_loss_percent, + help=('Packet loss in %. Default: %default')) + parser.add_option('-q', '--queue', type='int', + default=_DEFAULT_PRESET.queue_slots, + help=('Queue size as number of slots. Default: %default')) + parser.add_option('--port-range', default='%s,%s' % _DEFAULT_PORT_RANGE, + help=('Range of ports for constrained network. Specify as ' + 'two comma separated integers. Default: %default')) + parser.add_option('--target-ip', default=None, + help=('The interface IP address to apply the rules for. ' + 'Default: the external facing interface IP address.')) + parser.add_option('-v', '--verbose', action='store_true', default=False, + help=('Turn on verbose output. Will print all \'ipfw\' ' + 'commands that are executed.')) + + options = parser.parse_args()[0] + + # Find preset by ID, if specified: + if options.preset and not _PRESETS_DICT.has_key(options.preset): + parser.error('Invalid preset: %s' % options.preset) + + # Simple validation of the IP address, if supplied: + if options.target_ip: + try: + socket.inet_aton(options.target_ip) + except socket.error: + parser.error('Invalid IP address specified: %s' % options.target_ip) + + # Convert port range into the desired tuple format. + try: + if isinstance(options.port_range, str): + options.port_range = tuple(int(port) for port in + options.port_range.split(',')) + if len(options.port_range) != 2: + parser.error('Invalid port range specified, please specify two ' + 'integers separated by a comma.') + except ValueError: + parser.error('Invalid port range specified.') + + _set_logger(options.verbose) + return options + + +def _set_logger(verbose): + """Setup logging.""" + log_level = _DEFAULT_LOG_LEVEL + if verbose: + log_level = logging.DEBUG + logging.basicConfig(level=log_level, format='%(message)s') + + +def _main(): + """Checks arguments, permissions and runs a network simulation.""" + if os.name != 'posix': + print >> sys.stderr, 'This script is only supported on Linux and Mac.' + return 1 + + options = _parse_args() + + # Build a configuration object. Override any preset configuration settings if + # a value of a setting was also given as a flag. + connection_config = _PRESETS_DICT[options.preset] + if options.receive_bw: + connection_config.receive_bw_kbps = options.receive_bw + if options.send_bw: + connection_config.send_bw_kbps = options.send_bw + if options.delay: + connection_config.delay_ms = options.delay + if options.packet_loss: + connection_config.packet_loss_percent = options.packet_loss + if options.queue: + connection_config.queue_slots = options.queue + + simulator = network_simulator.NetworkSimulator(connection_config, + options.port_range) + try: + simulator.check_permissions() + except network_simulator.NetworkSimulatorError as e: + logging.error('Error: %s\n\nCause: %s', e.msg, e.error) + return -1 + + if not options.target_ip: + external_ip = _get_external_ip() + else: + external_ip = options.target_ip + + logging.info('Simulating traffic to/from IP: %s', external_ip) + simulator.simulate(external_ip) + logging.info('Started network simulation with the following configuration:\n' + ' Receive bandwidth: %s kbps (%s kB/s)\n' + ' Send bandwidth : %s kbps (%s kB/s)\n' + ' Delay : %s ms\n' + ' Packet loss : %s %%\n' + ' Queue slots : %s', + connection_config.receive_bw_kbps, + connection_config.receive_bw_kbps/8, + connection_config.send_bw_kbps, + connection_config.send_bw_kbps/8, + connection_config.delay_ms, + connection_config.packet_loss_percent, + connection_config.queue_slots) + logging.info('Affected traffic: IP traffic on ports %s-%s', + options.port_range[0], options.port_range[1]) + raw_input('Press Enter to abort Network simulation...') + logging.info('Flushing all Dummynet rules...') + simulator.cleanup() + logging.info('Completed Network Simulation.') + return 0 + +if __name__ == '__main__': + sys.exit(_main())