NETem - Network Link Emulator for GNS3
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

397 lines
12 KiB

#!/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 = "<No Limit>"
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 = "<None>"
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 = "<None>"
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)