NETem - Network Link Emulator for GNS3

netem-conf.py 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. #!/usr/bin/env python3
  2. #
  3. # netem-conf - configure NETem parameter
  4. #
  5. import copy
  6. import os
  7. import subprocess
  8. import json
  9. from dialog import Dialog
  10. # minimal config
  11. config = { 'eth0_to_eth1': {}, 'symmetric': True }
  12. # open dialog system
  13. d = Dialog(dialog="dialog", autowidgetsize=True)
  14. d.add_persistent_args(["--no-collapse"])
  15. # configure NETem parameter in linux
  16. def conf_netem(link, dev):
  17. # remove current config
  18. subprocess.call(['sudo', '-S', 'tc', 'qdisc', 'del', 'dev', dev, 'root'],
  19. stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
  20. stderr=subprocess.DEVNULL)
  21. # base NETem command line
  22. netem_cmd = ['sudo', '-S', 'tc', 'qdisc', 'add', 'dev', dev]
  23. # configure bandwidth with htb
  24. if config[link].get('bandwidth') is not None:
  25. buffer = max(int(0.3*config[link]['bandwidth']+0.5), 1600)
  26. bw_cmd = ['sudo', '-S', 'tc', 'qdisc', 'add', 'dev', dev,
  27. 'root', 'handle', '1:',
  28. 'tbf', 'rate', str(config[link]['bandwidth'])+"kbit",
  29. 'buffer', str(buffer), 'latency', '20ms']
  30. proc = subprocess.Popen(bw_cmd, stdin=subprocess.DEVNULL,
  31. stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
  32. out, err = proc.communicate()
  33. if err:
  34. err = err.decode('ascii').strip()
  35. if err == "Password:":
  36. err = "sudo needs password"
  37. d.msgbox("Can't configure bandwidth !!!\n\n" + \
  38. " ".join(bw_cmd) + "\n\n" + str(err))
  39. return False
  40. netem_cmd += ['parent', '1:1', 'handle', '10']
  41. else:
  42. netem_cmd += ['root', 'handle', '1']
  43. netem_cmd.append('netem')
  44. # add delay to command line
  45. if config[link].get('delay') is not None:
  46. netem_cmd.append("delay")
  47. netem_cmd.append(str(config[link]['delay']) + "ms")
  48. if config[link].get('jitter') is not None:
  49. netem_cmd.append(str(config[link]['jitter']) + "ms")
  50. # add loss to command line
  51. # see http://netgroup.uniroma2.it/TR/TR-loss-netem.pdf
  52. if config[link].get('loss') is not None:
  53. if config[link].get('loss_burst') is None:
  54. p13 = config[link]['loss']
  55. p31 = 100 - config[link]['loss']
  56. else:
  57. p13 = config[link]['loss'] / \
  58. (config[link]['loss_burst'] * (1 - config[link]['loss'] / 100))
  59. p31 = 100 / config[link]['loss_burst']
  60. netem_cmd.append("loss")
  61. netem_cmd.append("gemodel")
  62. netem_cmd.append(str(p13) + "%")
  63. netem_cmd.append(str(p31) + "%")
  64. netem_cmd.append("0")
  65. netem_cmd.append("0")
  66. # configure NETem parameter
  67. proc = subprocess.Popen(netem_cmd, stdin=subprocess.DEVNULL,
  68. stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
  69. out, err = proc.communicate()
  70. if err:
  71. err = err.decode('ascii').strip()
  72. if err == "Password:":
  73. err = "sudo needs password"
  74. d.msgbox("Can't configure NETem !!!\n\n" + \
  75. " ".join(netem_cmd) + "\n\n" + str(err))
  76. return False
  77. return True
  78. # bandwidth configuration of a link
  79. def string_bandwidth(link):
  80. if config[link].get('bandwidth') is None:
  81. bw_text = "<No Limit>"
  82. else:
  83. bw_text = str(config[link]['bandwidth']) + " kBit/s"
  84. return bw_text
  85. # delay configuration of a link
  86. def string_delay(link):
  87. if config[link].get('delay') is None:
  88. delay_text = "<None>"
  89. else:
  90. delay_text = str(config[link]['delay']) + " ms"
  91. if config[link].get('jitter') is not None:
  92. delay_text += ", Jitter: " + str(config[link]['jitter']) + " ms"
  93. return delay_text
  94. # loss configuration of a link
  95. def string_loss(link):
  96. if config[link].get('loss') is None:
  97. loss_text = "<None>"
  98. else:
  99. loss_text = str(config[link]['loss']) + " %"
  100. if config[link].get('loss_burst') is not None:
  101. loss_text += ", Burst: " + str(config[link]['loss_burst'])
  102. return loss_text
  103. # convert string to number
  104. def conv_num(string):
  105. string = string.strip()
  106. if string == "":
  107. x = None
  108. else:
  109. try:
  110. x = float(string)
  111. if abs(x) < 1e9 and x == int(x):
  112. x = int(x)
  113. except ValueError:
  114. raise ValueError("Invalid number: " + string)
  115. return x
  116. # convert string to postitive number (or zero)
  117. def conv_num_positive(string):
  118. x = conv_num(string)
  119. if x is not None and x < 0:
  120. raise ValueError("Negative number: " + string)
  121. return x
  122. # convert string to number greater or equal one
  123. def conv_num_ge_one(string):
  124. x = conv_num(string)
  125. if x is not None and x < 1:
  126. raise ValueError("Must be at least 1: " + string)
  127. return x
  128. # convert string to percentage
  129. def conv_num_percent(string):
  130. x = conv_num(string)
  131. if x is not None and (x < 0 or x > 100):
  132. raise ValueError("Percentage must be 0..100: " + string)
  133. return x
  134. # link parameter for parsing
  135. # ( variable, label, unit, conversion_function, input_width )
  136. link_param_bandwidth = [
  137. ( "bandwidth", "Bandwidth", "kBit/s", conv_num_positive, 10 ) ]
  138. link_param_delay = [
  139. ( "delay", "Delay", "ms", conv_num_positive, 10 ),
  140. ( "jitter", "Jitter", "ms", conv_num_positive, 10 ) ]
  141. link_param_loss = [
  142. ( "loss", "Packet Loss", "%", conv_num_percent, 10 ),
  143. ( "loss_burst", "Loss Burst", "Pkts", conv_num_ge_one, 10 ) ]
  144. link_param_all = link_param_bandwidth + link_param_delay + link_param_loss
  145. # get link configuration
  146. def get_link(link, link_params):
  147. global config
  148. title = link.replace('_to_', ' -> ')
  149. # convert link parameter to strings
  150. fields = []
  151. for param in link_params:
  152. val = config[link].get(param[0])
  153. if val is None:
  154. val = ""
  155. else:
  156. val = str(val)
  157. fields.append(val)
  158. # get parameter, until no errors left
  159. ok = False
  160. while not ok:
  161. # create elements array for dialog.form
  162. elements = []
  163. i = 0
  164. for param in link_params:
  165. label = param[1]
  166. if param[2] is not None:
  167. label += " [" + param[2] + "]"
  168. elements.append((label, i+1, 2, fields[i], i+1, 22, param[4], 0))
  169. i += 1
  170. # get parameter
  171. code, fields = d.form("Link configuration " + title, elements,
  172. title=" "+title+" ")
  173. if code != Dialog.OK:
  174. break
  175. # convert string fields to data
  176. data = {}
  177. ok = True
  178. i = 0
  179. for param in link_params:
  180. try:
  181. data[param[0]] = param[3](fields[i])
  182. except ValueError as err:
  183. ok = False
  184. d.msgbox("Input error !!!\n\n" + param[1] + ":\n" + str(err))
  185. break
  186. i += 1
  187. # additinal checks
  188. if ok and data.get('delay') is not None and \
  189. data.get('jitter') is not None and \
  190. data['jitter'] > data['delay']:
  191. ok = False
  192. d.msgbox("Input error !!!\n\nJitter must be less than delay.")
  193. # all fine, handle some special values and copy data to link config
  194. if ok:
  195. if data.get('delay') == 0:
  196. data['delay'] = None
  197. if data.get('delay') is None or data.get('jitter') == 0:
  198. data['jitter'] = None
  199. if data.get('loss') == 0:
  200. data['loss'] = None
  201. if data.get('loss') is None or data.get('loss_burst') == 1:
  202. data['loss_burst'] = None
  203. for param in data:
  204. config[link][param] = data[param]
  205. return
  206. # menu functions
  207. def menu_0to1():
  208. get_link('eth0_to_eth1', link_param_all)
  209. def menu_0to1_bandwidth():
  210. get_link('eth0_to_eth1', link_param_bandwidth)
  211. def menu_0to1_delay():
  212. get_link('eth0_to_eth1', link_param_delay)
  213. def menu_0to1_loss():
  214. get_link('eth0_to_eth1', link_param_loss)
  215. def menu_asymmetric():
  216. global config
  217. code = d.yesno("Do you want to change to symmetric mode?")
  218. if code == Dialog.OK:
  219. config['symmetric'] = True
  220. del config['eth1_to_eth0']
  221. def menu_symmetric():
  222. global config
  223. code = d.yesno("Do you want to change to asymmetric mode?")
  224. if code == Dialog.OK:
  225. config['symmetric'] = False
  226. config['eth1_to_eth0'] = copy.deepcopy(config['eth0_to_eth1'])
  227. def menu_1to0():
  228. if config['symmetric']:
  229. menu_symmetric()
  230. else:
  231. get_link('eth1_to_eth0', link_param_all)
  232. def menu_1to0_bandwidth():
  233. get_link('eth1_to_eth0', link_param_bandwidth)
  234. def menu_1to0_delay():
  235. get_link('eth1_to_eth0', link_param_delay)
  236. def menu_1to0_loss():
  237. get_link('eth1_to_eth0', link_param_loss)
  238. def menu_load():
  239. global config
  240. title = " Load Configuration "
  241. code, path = d.fselect("configs/", 10, 60, title=title)
  242. if code == Dialog.OK:
  243. try:
  244. with open(path, "r") as f:
  245. config = json.load(f)
  246. except (ValueError, IOError, OSError) as err:
  247. d.msgbox("Error !!!\n\n" + str(err), title=title)
  248. def menu_save():
  249. title = " Save Configuration "
  250. code, path = d.fselect("configs/", 10, 60, title=title)
  251. if code == Dialog.OK:
  252. try:
  253. with open(path, "w") as f:
  254. json.dump(config, f, sort_keys=True, indent=4,
  255. separators=(',', ': '))
  256. f.write("\n")
  257. except (ValueError, IOError, OSError) as err:
  258. d.msgbox("Error !!!\n\n" + str(err), title=title)
  259. # backup to persistent disk
  260. subprocess.call(['filetool.sh', '-b'],
  261. stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
  262. stderr=subprocess.DEVNULL)
  263. def menu_shell():
  264. d.clear()
  265. print('Starting sub-shell, return with "exit"...')
  266. subprocess.call('/bin/sh')
  267. def menu_shutdown():
  268. d.clear()
  269. subprocess.call(['sudo', 'poweroff'])
  270. menu_functions = {
  271. 'eth0->eth1': menu_0to1,
  272. ' Bandwidth': menu_0to1_bandwidth,
  273. ' Delay': menu_0to1_delay,
  274. ' Loss': menu_0to1_loss,
  275. 'eth1->eth0': menu_1to0,
  276. ' Asymmetric': menu_asymmetric,
  277. ' Symmetric': menu_symmetric,
  278. ' Bandwidth ': menu_1to0_bandwidth,
  279. ' Delay ': menu_1to0_delay,
  280. ' Loss ': menu_1to0_loss,
  281. 'Load': menu_load,
  282. 'Save': menu_save,
  283. 'Shell': menu_shell,
  284. 'Shutdown': menu_shutdown
  285. }
  286. # Main starts here
  287. try:
  288. # create config subdirectory
  289. os.makedirs("configs", exist_ok=True)
  290. # try to load initial configuration
  291. try:
  292. with open("configs/init", "r") as f:
  293. config = json.load(f)
  294. except (ValueError, IOError, OSError):
  295. pass
  296. # input loop
  297. while True:
  298. # set parameter in linux
  299. if conf_netem('eth0_to_eth1', 'eth1'):
  300. if config['symmetric']:
  301. conf_netem('eth0_to_eth1', 'eth0')
  302. else:
  303. conf_netem('eth1_to_eth0', 'eth0')
  304. # main menue
  305. choices = [ ('eth0->eth1', "Configure link eth0 -> eth1"),
  306. (' Bandwidth', string_bandwidth('eth0_to_eth1')),
  307. (' Delay', string_delay('eth0_to_eth1')),
  308. (' Loss', string_loss('eth0_to_eth1')),
  309. ('eth1->eth0', "Configure link eth1 -> eth0") ]
  310. if config['symmetric']:
  311. choices += [ (' Symmetric', "Same config as eth0 -> eth1") ]
  312. else:
  313. choices += [ (' Asymmetric', "Use specific configuration"),
  314. (' Bandwidth ', string_bandwidth('eth1_to_eth0')),
  315. (' Delay ', string_delay('eth1_to_eth0')),
  316. (' Loss ', string_loss('eth1_to_eth0')) ]
  317. choices += [ ("Load", "Load configuration from file"),
  318. ("Save", "Save configuration to file"),
  319. ("Shell", "Open a console"),
  320. ("Shutdown", "Shutdown the VM") ]
  321. code, tag = d.menu("NETem Configuration", choices=choices,
  322. title=" NETem Configuration ", no_cancel=True)
  323. if code == Dialog.OK and tag in menu_functions:
  324. menu_functions[tag]()
  325. # intercept Ctrl-C
  326. except KeyboardInterrupt:
  327. d.clear()
  328. exit(0)