#!/usr/bin/env python3 # # netem-conf - configure NETem parameter # import copy import os import subprocess import json from dialog import Dialog # minimal config config = { 'eth0_to_eth1': {}, 'symmetric': True } # open dialog system d = Dialog(dialog="dialog", autowidgetsize=True) d.add_persistent_args(["--no-collapse"]) # configure NETem parameter in linux def conf_netem(link, dev): # remove current config subprocess.call(['sudo', '-S', 'tc', 'qdisc', 'del', 'dev', dev, 'root'], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) # base NETem command line netem_cmd = ['sudo', '-S', 'tc', 'qdisc', 'add', 'dev', dev] # configure bandwidth with htb if config[link].get('bandwidth') is not None: buffer = max(int(0.3*config[link]['bandwidth']+0.5), 1600) bw_cmd = ['sudo', '-S', 'tc', 'qdisc', 'add', 'dev', dev, 'root', 'handle', '1:', 'tbf', 'rate', str(config[link]['bandwidth'])+"kbit", 'buffer', str(buffer), 'latency', '20ms'] proc = subprocess.Popen(bw_cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) out, err = proc.communicate() if err: err = err.decode('ascii').strip() if err == "Password:": err = "sudo needs password" d.msgbox("Can't configure bandwidth !!!\n\n" + \ " ".join(bw_cmd) + "\n\n" + str(err)) return False netem_cmd += ['parent', '1:1', 'handle', '10'] else: netem_cmd += ['root', 'handle', '1'] netem_cmd.append('netem') # add delay to command line if config[link].get('delay') is not None: netem_cmd.append("delay") netem_cmd.append(str(config[link]['delay']) + "ms") if config[link].get('jitter') is not None: netem_cmd.append(str(config[link]['jitter']) + "ms") # add loss to command line # see http://netgroup.uniroma2.it/TR/TR-loss-netem.pdf if config[link].get('loss') is not None: if config[link].get('loss_burst') is None: p13 = config[link]['loss'] p31 = 100 - config[link]['loss'] else: p13 = config[link]['loss'] / \ (config[link]['loss_burst'] * (1 - config[link]['loss'] / 100)) p31 = 100 / config[link]['loss_burst'] netem_cmd.append("loss") netem_cmd.append("gemodel") netem_cmd.append(str(p13) + "%") netem_cmd.append(str(p31) + "%") netem_cmd.append("0") netem_cmd.append("0") # configure NETem parameter proc = subprocess.Popen(netem_cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) out, err = proc.communicate() if err: err = err.decode('ascii').strip() if err == "Password:": err = "sudo needs password" d.msgbox("Can't configure NETem !!!\n\n" + \ " ".join(netem_cmd) + "\n\n" + str(err)) return False return True # bandwidth configuration of a link def string_bandwidth(link): if config[link].get('bandwidth') is None: bw_text = "" else: bw_text = str(config[link]['bandwidth']) + " kBit/s" return bw_text # delay configuration of a link def string_delay(link): if config[link].get('delay') is None: delay_text = "" else: delay_text = str(config[link]['delay']) + " ms" if config[link].get('jitter') is not None: delay_text += ", Jitter: " + str(config[link]['jitter']) + " ms" return delay_text # loss configuration of a link def string_loss(link): if config[link].get('loss') is None: loss_text = "" else: loss_text = str(config[link]['loss']) + " %" if config[link].get('loss_burst') is not None: loss_text += ", Burst: " + str(config[link]['loss_burst']) return loss_text # convert string to number def conv_num(string): string = string.strip() if string == "": x = None else: try: x = float(string) if abs(x) < 1e9 and x == int(x): x = int(x) except ValueError: raise ValueError("Invalid number: " + string) return x # convert string to postitive number (or zero) def conv_num_positive(string): x = conv_num(string) if x is not None and x < 0: raise ValueError("Negative number: " + string) return x # convert string to number greater or equal one def conv_num_ge_one(string): x = conv_num(string) if x is not None and x < 1: raise ValueError("Must be at least 1: " + string) return x # convert string to percentage def conv_num_percent(string): x = conv_num(string) if x is not None and (x < 0 or x > 100): raise ValueError("Percentage must be 0..100: " + string) return x # link parameter for parsing # ( variable, label, unit, conversion_function, input_width ) link_param_bandwidth = [ ( "bandwidth", "Bandwidth", "kBit/s", conv_num_positive, 10 ) ] link_param_delay = [ ( "delay", "Delay", "ms", conv_num_positive, 10 ), ( "jitter", "Jitter", "ms", conv_num_positive, 10 ) ] link_param_loss = [ ( "loss", "Packet Loss", "%", conv_num_percent, 10 ), ( "loss_burst", "Loss Burst", "Pkts", conv_num_ge_one, 10 ) ] link_param_all = link_param_bandwidth + link_param_delay + link_param_loss # get link configuration def get_link(link, link_params): global config title = link.replace('_to_', ' -> ') # convert link parameter to strings fields = [] for param in link_params: val = config[link].get(param[0]) if val is None: val = "" else: val = str(val) fields.append(val) # get parameter, until no errors left ok = False while not ok: # create elements array for dialog.form elements = [] i = 0 for param in link_params: label = param[1] if param[2] is not None: label += " [" + param[2] + "]" elements.append((label, i+1, 2, fields[i], i+1, 22, param[4], 0)) i += 1 # get parameter code, fields = d.form("Link configuration " + title, elements, title=" "+title+" ") if code != Dialog.OK: break # convert string fields to data data = {} ok = True i = 0 for param in link_params: try: data[param[0]] = param[3](fields[i]) except ValueError as err: ok = False d.msgbox("Input error !!!\n\n" + param[1] + ":\n" + str(err)) break i += 1 # additinal checks if ok and data.get('delay') is not None and \ data.get('jitter') is not None and \ data['jitter'] > data['delay']: ok = False d.msgbox("Input error !!!\n\nJitter must be less than delay.") # all fine, handle some special values and copy data to link config if ok: if data.get('delay') == 0: data['delay'] = None if data.get('delay') is None or data.get('jitter') == 0: data['jitter'] = None if data.get('loss') == 0: data['loss'] = None if data.get('loss') is None or data.get('loss_burst') == 1: data['loss_burst'] = None for param in data: config[link][param] = data[param] return # menu functions def menu_0to1(): get_link('eth0_to_eth1', link_param_all) def menu_0to1_bandwidth(): get_link('eth0_to_eth1', link_param_bandwidth) def menu_0to1_delay(): get_link('eth0_to_eth1', link_param_delay) def menu_0to1_loss(): get_link('eth0_to_eth1', link_param_loss) def menu_asymmetric(): global config code = d.yesno("Do you want to change to symmetric mode?") if code == Dialog.OK: config['symmetric'] = True del config['eth1_to_eth0'] def menu_symmetric(): global config code = d.yesno("Do you want to change to asymmetric mode?") if code == Dialog.OK: config['symmetric'] = False config['eth1_to_eth0'] = copy.deepcopy(config['eth0_to_eth1']) def menu_1to0(): if config['symmetric']: menu_symmetric() else: get_link('eth1_to_eth0', link_param_all) def menu_1to0_bandwidth(): get_link('eth1_to_eth0', link_param_bandwidth) def menu_1to0_delay(): get_link('eth1_to_eth0', link_param_delay) def menu_1to0_loss(): get_link('eth1_to_eth0', link_param_loss) def menu_load(): global config title = " Load Configuration " code, path = d.fselect("configs/", 10, 60, title=title) if code == Dialog.OK: try: with open(path, "r") as f: config = json.load(f) except (ValueError, IOError, OSError) as err: d.msgbox("Error !!!\n\n" + str(err), title=title) def menu_save(): title = " Save Configuration " code, path = d.fselect("configs/", 10, 60, title=title) if code == Dialog.OK: try: with open(path, "w") as f: json.dump(config, f, sort_keys=True, indent=4, separators=(',', ': ')) f.write("\n") except (ValueError, IOError, OSError) as err: d.msgbox("Error !!!\n\n" + str(err), title=title) # backup to persistent disk subprocess.call(['filetool.sh', '-b'], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) def menu_shell(): d.clear() print('Starting sub-shell, return with "exit"...') subprocess.call('/bin/sh') def menu_shutdown(): d.clear() subprocess.call(['sudo', 'poweroff']) menu_functions = { 'eth0->eth1': menu_0to1, ' Bandwidth': menu_0to1_bandwidth, ' Delay': menu_0to1_delay, ' Loss': menu_0to1_loss, 'eth1->eth0': menu_1to0, ' Asymmetric': menu_asymmetric, ' Symmetric': menu_symmetric, ' Bandwidth ': menu_1to0_bandwidth, ' Delay ': menu_1to0_delay, ' Loss ': menu_1to0_loss, 'Load': menu_load, 'Save': menu_save, 'Shell': menu_shell, 'Shutdown': menu_shutdown } # Main starts here try: # create config subdirectory os.makedirs("configs", exist_ok=True) # try to load initial configuration try: with open("configs/init", "r") as f: config = json.load(f) except (ValueError, IOError, OSError): pass # input loop while True: # set parameter in linux if conf_netem('eth0_to_eth1', 'eth1'): if config['symmetric']: conf_netem('eth0_to_eth1', 'eth0') else: conf_netem('eth1_to_eth0', 'eth0') # main menue choices = [ ('eth0->eth1', "Configure link eth0 -> eth1"), (' Bandwidth', string_bandwidth('eth0_to_eth1')), (' Delay', string_delay('eth0_to_eth1')), (' Loss', string_loss('eth0_to_eth1')), ('eth1->eth0', "Configure link eth1 -> eth0") ] if config['symmetric']: choices += [ (' Symmetric', "Same config as eth0 -> eth1") ] else: choices += [ (' Asymmetric', "Use specific configuration"), (' Bandwidth ', string_bandwidth('eth1_to_eth0')), (' Delay ', string_delay('eth1_to_eth0')), (' Loss ', string_loss('eth1_to_eth0')) ] choices += [ ("Load", "Load configuration from file"), ("Save", "Save configuration to file"), ("Shell", "Open a console"), ("Shutdown", "Shutdown the VM") ] code, tag = d.menu("NETem Configuration", choices=choices, title=" NETem Configuration ", no_cancel=True) if code == Dialog.OK and tag in menu_functions: menu_functions[tag]() # intercept Ctrl-C except KeyboardInterrupt: d.clear() exit(0)