├── MANIFEST.in ├── results.500bytes.xClients.0.166mps.pdf ├── beem ├── cmds │ ├── __init__.py │ ├── watch.py │ ├── keygen.py │ ├── subscribe.py │ └── publish.py ├── main.py ├── trackers.py ├── msgs.py ├── __init__.py ├── load.py ├── bridge.py └── listen.py ├── .gitignore ├── warheads ├── 125x0.166mps--1000x500bytes.wh ├── simple_2x100-T1_publish.warhead ├── simple_200x1000-T5_publish.warhead ├── 150x0.166mps--1000x500bytes.qos0.wh.nossl ├── 150x0.166mps--1000x5bytes.qos0.wh.nossl ├── 100x1mps--1000x500bytes.wh ├── 100x0.166mps--1000x50bytes.wh ├── 50x0.166mps--1000x500bytes.wh ├── 100x0.166mps--1000x5000bytes.wh ├── 100x0.166mps--1000x500bytes.wh ├── 150x0.066mps--1000x500bytes.wh ├── 150x0.166mps--1000x500bytes.wh ├── 200x0.166mps--1000x500bytes.wh ├── 150x0.166mps--1000x500bytes.qos0.wh ├── complex_10x10-bursty-double_publish.warhead └── README ├── vagrant.bootstrap.sh ├── tox.ini ├── .project ├── .pydevproject ├── Vagrantfile ├── LICENSE.txt ├── setup.py ├── malaria ├── version.py ├── README-swarm.md ├── README.md └── fabfile.py /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include README-swarm.md 3 | include RELEASE-VERSION 4 | include version.py 5 | -------------------------------------------------------------------------------- /results.500bytes.xClients.0.166mps.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/etactica/mqtt-malaria/HEAD/results.500bytes.xClients.0.166mps.pdf -------------------------------------------------------------------------------- /beem/cmds/__init__.py: -------------------------------------------------------------------------------- 1 | import beem.cmds.publish 2 | import beem.cmds.subscribe 3 | import beem.cmds.keygen 4 | import beem.cmds.watch 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .*.swp 3 | 4 | .vagrant 5 | .env 6 | .venv 7 | 8 | RELEASE-VERSION 9 | dist 10 | *.egg-info 11 | *.egg 12 | 13 | .tox 14 | -------------------------------------------------------------------------------- /warheads/125x0.166mps--1000x500bytes.wh: -------------------------------------------------------------------------------- 1 | malaria publish -p 8883 -b --psk_file /tmp/malaria-tmp-keyfile -P 125 -n 1000 -T 0.166 -s 500 -t -H %(malaria_target)s 2 | -------------------------------------------------------------------------------- /warheads/simple_2x100-T1_publish.warhead: -------------------------------------------------------------------------------- 1 | # Publish 100 messages from 2 clients at 1 pps from each worker 2 | malaria publish -t -T 1 -n 100 -P 2 -H %(malaria_target)s 3 | -------------------------------------------------------------------------------- /warheads/simple_200x1000-T5_publish.warhead: -------------------------------------------------------------------------------- 1 | # Publish 1000 messages from 200 clients at 5 pps from each worker 2 | malaria publish -t -T 5 -n 1000 -P 200 -H %(malaria_target)s 3 | -------------------------------------------------------------------------------- /warheads/150x0.166mps--1000x500bytes.qos0.wh.nossl: -------------------------------------------------------------------------------- 1 | # 100 clients at 1/6 Hz, 500 bytes, for 1000 mesages 2 | malaria publish -q 0 -b -P 150 -n 1000 -T 0.166 -s 500 -t -H %(malaria_target)s 3 | -------------------------------------------------------------------------------- /warheads/150x0.166mps--1000x5bytes.qos0.wh.nossl: -------------------------------------------------------------------------------- 1 | # 150 clients at 1/6 Hz, 5 bytes, for 1000 mesages NO SSL 2 | malaria publish -q 0 -b -P 150 -n 1000 -T 0.166 -s 5 -t -H %(malaria_target)s 3 | -------------------------------------------------------------------------------- /warheads/100x1mps--1000x500bytes.wh: -------------------------------------------------------------------------------- 1 | # 100 clients at 1 mps, 500 bytes, for 1000 mesages 2 | malaria publish -p 8883 -b --psk_file /tmp/malaria-tmp-keyfile -P 100 -n 1000 -T 1 -s 500 -t -H %(malaria_target)s 3 | -------------------------------------------------------------------------------- /warheads/100x0.166mps--1000x50bytes.wh: -------------------------------------------------------------------------------- 1 | # 100 clients at 1/6 Hz, 50 bytes, for 1000 mesages 2 | malaria publish -p 8883 -b --psk_file /tmp/malaria-tmp-keyfile -P 100 -n 1000 -T 0.166 -s 50 -t -H %(malaria_target)s 3 | -------------------------------------------------------------------------------- /warheads/50x0.166mps--1000x500bytes.wh: -------------------------------------------------------------------------------- 1 | # 50 clients at 1/6 Hz, 500 bytes, for 1000 mesages 2 | malaria publish -p 8883 -b --psk_file /tmp/malaria-tmp-keyfile -P 50 -n 1000 -T 0.166 -s 500 -t -H %(malaria_target)s 3 | -------------------------------------------------------------------------------- /warheads/100x0.166mps--1000x5000bytes.wh: -------------------------------------------------------------------------------- 1 | # 100 clients at 1/6 Hz, 5000 bytes, for 1000 mesages 2 | malaria publish -p 8883 -b --psk_file /tmp/malaria-tmp-keyfile -P 100 -n 1000 -T 0.166 -s 5000 -t -H %(malaria_target)s 3 | -------------------------------------------------------------------------------- /warheads/100x0.166mps--1000x500bytes.wh: -------------------------------------------------------------------------------- 1 | # 100 clients at 1/6 Hz, 500 bytes, for 1000 mesages 2 | malaria publish -p 8883 -b --psk_file /tmp/malaria-tmp-keyfile -P 100 -n 1000 -T 0.166 -s 500 -t -H %(malaria_target)s 3 | -------------------------------------------------------------------------------- /warheads/150x0.066mps--1000x500bytes.wh: -------------------------------------------------------------------------------- 1 | # 150 clients at 1/15 Hz, 500 bytes, for 1000 mesages 2 | malaria publish -p 8883 -b --psk_file /tmp/malaria-tmp-keyfile -P 150 -n 1000 -T 0.066 -s 500 -t -H %(malaria_target)s 3 | -------------------------------------------------------------------------------- /warheads/150x0.166mps--1000x500bytes.wh: -------------------------------------------------------------------------------- 1 | # 100 clients at 1/6 Hz, 500 bytes, for 1000 mesages 2 | malaria publish -p 8883 -b --psk_file /tmp/malaria-tmp-keyfile -P 150 -n 1000 -T 0.166 -s 500 -t -H %(malaria_target)s 3 | -------------------------------------------------------------------------------- /warheads/200x0.166mps--1000x500bytes.wh: -------------------------------------------------------------------------------- 1 | # 200 clients at 1/6 Hz, 500 bytes, for 1000 mesages 2 | malaria publish -p 8883 -b --psk_file /tmp/malaria-tmp-keyfile -P 200 -n 1000 -T 0.166 -s 500 -t -H %(malaria_target)s 3 | -------------------------------------------------------------------------------- /warheads/150x0.166mps--1000x500bytes.qos0.wh: -------------------------------------------------------------------------------- 1 | # 100 clients at 1/6 Hz, 500 bytes, for 1000 mesages 2 | malaria publish -q 0 -p 8883 -b --psk_file /tmp/malaria-tmp-keyfile -P 150 -n 1000 -T 0.166 -s 500 -t -H %(malaria_target)s 3 | -------------------------------------------------------------------------------- /warheads/complex_10x10-bursty-double_publish.warhead: -------------------------------------------------------------------------------- 1 | # Publish 10 messages from 10 clients as fast as we can from each worker 2 | malaria publish -t -n 10 -P 10 -H %(malaria_target)s 3 | # Then pause a little 4 | sleep 4 5 | # then run it again! 6 | malaria publish -t -n 10 -P 10 -H %(malaria_target)s 7 | -------------------------------------------------------------------------------- /warheads/README: -------------------------------------------------------------------------------- 1 | files in this directory can be run in a virtualenv as a "warhead" for the attack function 2 | The files contain lists of commands that are simply passed to fabric's "run" command. 3 | A variety of variables are available in the fabric environment, most usefully, 4 | malaria_target: the hostname targetted for attack 5 | -------------------------------------------------------------------------------- /vagrant.bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | apt-get -y install python-software-properties avahi-daemon 3 | apt-add-repository ppa:mosquitto-dev/mosquitto-ppa 4 | 5 | apt-get -y update 6 | apt-get -y install mosquitto python-mosquitto mosquitto-clients 7 | 8 | # You need this if you're setting up as a target, not a bee 9 | #cp /etc/mosquitto/mosquitto.conf.example /etc/mosquitto/mosquitto.conf 10 | #start mosquitto 11 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | #envlist = py26, py27, py32, py33 8 | envlist = py27, py33 9 | 10 | [testenv] 11 | commands = {envpython} setup.py test 12 | deps = 13 | paho-mqtt>=1.1 14 | -------------------------------------------------------------------------------- /.project: -------------------------------------------------------------------------------- 1 | 2 | 3 | MqttMalaria 4 | 5 | 6 | 7 | 8 | 9 | org.python.pydev.PyDevBuilder 10 | 11 | 12 | 13 | 14 | 15 | org.python.pydev.pythonNature 16 | 17 | 18 | -------------------------------------------------------------------------------- /.pydevproject: -------------------------------------------------------------------------------- 1 | 2 | 3 | Default 4 | python 2.7 5 | 6 | 7 | 8 | /MqttMalaria 9 | 10 | 11 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | # Basic vagrant setup for two bees 5 | # A private network of 192.168.5.x is setup, 6 | # It should be easy to see how to add more vms if you want to experiment 7 | 8 | Vagrant.configure("2") do |config| 9 | servers = { 10 | :bee1 => "192.168.5.201", 11 | :bee2 => "192.168.5.202" 12 | } 13 | 14 | servers.each do |server_id, server_ip| 15 | config.vm.define server_id do |app_config| 16 | app_config.vm.box = "hashicorp/precise32" 17 | app_config.vm.hostname = server_id.to_s 18 | app_config.vm.network :private_network, ip: server_ip 19 | app_config.vm.provision :shell, :path => "vagrant.bootstrap.sh" 20 | end 21 | end 22 | 23 | end 24 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, ReMake Electric ehf 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 15 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 17 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 18 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 19 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 20 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 21 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 22 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | import os 3 | import version 4 | 5 | def read(fname): 6 | return open(os.path.join(os.path.dirname(__file__), fname)).read() 7 | 8 | setup( 9 | name='mqtt-malaria', 10 | url="https://github.com/etactica/mqtt-malaria", 11 | maintainer="eTactica ehf. - Software Department", 12 | maintainer_email="technical@etactica.com", 13 | version=version.get_git_version(), 14 | description="Attacking MQTT systems with Mosquittos", 15 | long_description=read('README.md'), 16 | license="License :: OSI Approved :: BSD License", 17 | scripts=["malaria"], 18 | packages=[ 19 | 'beem', 20 | 'beem.cmds' 21 | ], 22 | include_package_data=True, 23 | zip_safe=False, 24 | install_requires=[ 25 | 'paho-mqtt>=1.1', 26 | 'fusepy' 27 | ], 28 | tests_require=[ 29 | 'fabric', 30 | 'fabtools', 31 | 'nose', 32 | 'coverage' 33 | ], 34 | classifiers=[ 35 | "Development Status :: 3 - Alpha", 36 | "Environment :: Console", 37 | "Intended Audience :: Developers", 38 | "Intended Audience :: System Administrators", 39 | "Programming Language :: Python", 40 | "Topic :: Software Development :: Quality Assurance", 41 | "Topic :: Software Development :: Testing", 42 | "Topic :: Software Development :: Testing :: Traffic Generation", 43 | "Topic :: System :: Benchmark", 44 | "Topic :: System :: Networking" 45 | ] 46 | ) 47 | -------------------------------------------------------------------------------- /malaria: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Copyright (c) 2013, ReMake Electric ehf 4 | # All rights reserved. 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are met: 8 | # 9 | # 1. Redistributions of source code must retain the above copyright notice, 10 | # this list of conditions and the following disclaimer. 11 | # 2. Redistributions in binary form must reproduce the above copyright 12 | # notice, this list of conditions and the following disclaimer in the 13 | # documentation and/or other materials provided with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 16 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 17 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 18 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 19 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 20 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 21 | # SUBSTITUTE GOODS OR SERVICES LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 22 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 23 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 24 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 25 | # POSSIBILITY OF SUCH DAMAGE. 26 | """ 27 | bootstrap script to provide a friendly front end 28 | """ 29 | 30 | import beem.main 31 | beem.main.main() 32 | -------------------------------------------------------------------------------- /beem/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Copyright (c) 2013, ReMake Electric ehf 4 | # All rights reserved. 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are met: 8 | # 9 | # 1. Redistributions of source code must retain the above copyright notice, 10 | # this list of conditions and the following disclaimer. 11 | # 2. Redistributions in binary form must reproduce the above copyright 12 | # notice, this list of conditions and the following disclaimer in the 13 | # documentation and/or other materials provided with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 16 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 17 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 18 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 19 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 20 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 21 | # SUBSTITUTE GOODS OR SERVICES LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 22 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 23 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 24 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 25 | # POSSIBILITY OF SUCH DAMAGE. 26 | """ 27 | Dispatcher for running any of the MQTT Malaria tools 28 | """ 29 | 30 | import argparse 31 | import logging 32 | import sys 33 | 34 | import beem.cmds 35 | 36 | logging.basicConfig(level=logging.INFO, stream=sys.stdout) 37 | 38 | 39 | def main(): 40 | parser = argparse.ArgumentParser( 41 | description=""" 42 | Malaria MQTT testing tool 43 | """) 44 | 45 | subparsers = parser.add_subparsers(title="subcommands") 46 | 47 | beem.cmds.publish.add_args(subparsers) 48 | beem.cmds.subscribe.add_args(subparsers) 49 | beem.cmds.keygen.add_args(subparsers) 50 | beem.cmds.watch.add_args(subparsers) 51 | 52 | options = parser.parse_args() 53 | options.handler(options) 54 | 55 | 56 | if __name__ == "__main__": 57 | main() 58 | -------------------------------------------------------------------------------- /beem/cmds/watch.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Copyright (c) 2013, ReMake Electric ehf 4 | # All rights reserved. 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are met: 8 | # 9 | # 1. Redistributions of source code must retain the above copyright notice, 10 | # this list of conditions and the following disclaimer. 11 | # 2. Redistributions in binary form must reproduce the above copyright 12 | # notice, this list of conditions and the following disclaimer in the 13 | # documentation and/or other materials provided with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 16 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 17 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 18 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 19 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 20 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 21 | # SUBSTITUTE GOODS OR SERVICES LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 22 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 23 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 24 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 25 | # POSSIBILITY OF SUCH DAMAGE. 26 | # 27 | # This file implements the "malaria watch" command 28 | """ 29 | Listen to a stream of messages and passively collect long term stats 30 | """ 31 | 32 | import argparse 33 | import os 34 | import beem.listen 35 | 36 | 37 | def add_args(subparsers): 38 | parser = subparsers.add_parser( 39 | "watch", 40 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 41 | description=__doc__, 42 | help="Idly watch a stream of messages go past") 43 | 44 | parser.add_argument( 45 | "-c", "--clientid", default="beem.watchr-%d" % os.getpid(), 46 | help="""Set the client id of the listner, can be useful for acls 47 | Default has pid information appended. 48 | """) 49 | parser.add_argument( 50 | "-H", "--host", default="localhost", 51 | help="MQTT host to connect to") 52 | parser.add_argument( 53 | "-p", "--port", type=int, default=1883, 54 | help="Port for remote MQTT host") 55 | parser.add_argument( 56 | "-q", "--qos", type=int, choices=[0, 1, 2], 57 | help="set the mqtt qos for subscription", default=1) 58 | parser.add_argument( 59 | "-t", "--topic", default=[], action="append", 60 | help="""Topic to subscribe to, will be sorted into clients by the 61 | '+' symbol if available. Will actually default to "#" if no custom 62 | topics are provided""") 63 | parser.add_argument( 64 | "-d", "--directory", help="Directory to publish statistics FS to") 65 | 66 | parser.set_defaults(handler=run) 67 | 68 | 69 | def run(options): 70 | if not len(options.topic): 71 | options.topic = ["#"] 72 | beem.listen.CensusListener(options) 73 | -------------------------------------------------------------------------------- /version.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Author: Douglas Creager 3 | # This file is placed into the public domain. 4 | 5 | # Calculates the current version number. If possible, this is the 6 | # output of “git describe”, modified to conform to the versioning 7 | # scheme that setuptools uses. If “git describe” returns an error 8 | # (most likely because we're in an unpacked copy of a release tarball, 9 | # rather than in a git working copy), then we fall back on reading the 10 | # contents of the RELEASE-VERSION file. 11 | # 12 | # To use this script, simply import it your setup.py file, and use the 13 | # results of get_git_version() as your package version: 14 | # 15 | # from version import * 16 | # 17 | # setup( 18 | # version=get_git_version(), 19 | # . 20 | # . 21 | # . 22 | # ) 23 | # 24 | # This will automatically update the RELEASE-VERSION file, if 25 | # necessary. Note that the RELEASE-VERSION file should *not* be 26 | # checked into git; please add it to your top-level .gitignore file. 27 | # 28 | # You'll probably want to distribute the RELEASE-VERSION file in your 29 | # sdist tarballs; to do this, just create a MANIFEST.in file that 30 | # contains the following line: 31 | # 32 | # include RELEASE-VERSION 33 | 34 | __all__ = ("get_git_version") 35 | 36 | from subprocess import Popen, PIPE 37 | 38 | 39 | def call_git_describe(abbrev): 40 | try: 41 | p = Popen(['git', 'describe', '--abbrev=%d' % abbrev, "--dirty"], 42 | stdout=PIPE, stderr=PIPE) 43 | p.stderr.close() 44 | line = p.stdout.readlines()[0] 45 | return line.strip() 46 | 47 | except: 48 | return None 49 | 50 | 51 | def read_release_version(): 52 | try: 53 | f = open("RELEASE-VERSION", "r") 54 | 55 | try: 56 | version = f.readlines()[0] 57 | return version.strip() 58 | 59 | finally: 60 | f.close() 61 | 62 | except: 63 | return None 64 | 65 | 66 | def write_release_version(version): 67 | f = open("RELEASE-VERSION", "w") 68 | f.write("%s\n" % version) 69 | f.close() 70 | 71 | 72 | def get_git_version(abbrev=7): 73 | # Read in the version that's currently in RELEASE-VERSION. 74 | 75 | release_version = read_release_version() 76 | 77 | # First try to get the current version using “git describe”. 78 | 79 | version = call_git_describe(abbrev) 80 | 81 | # If that doesn't work, fall back on the value that's in 82 | # RELEASE-VERSION. 83 | 84 | if version is None: 85 | version = release_version 86 | 87 | # If we still don't have anything, that's an error. 88 | 89 | if version is None: 90 | raise ValueError("Cannot find the version number!") 91 | 92 | # If the current version is different from what's in the 93 | # RELEASE-VERSION file, update the file to be current. 94 | 95 | if version != release_version: 96 | write_release_version(version) 97 | 98 | # Finally, return the current version. 99 | 100 | return version 101 | 102 | 103 | if __name__ == "__main__": 104 | print(get_git_version()) 105 | -------------------------------------------------------------------------------- /beem/trackers.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013, ReMake Electric ehf 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # 7 | # 1. Redistributions of source code must retain the above copyright notice, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright 10 | # notice, this list of conditions and the following disclaimer in the 11 | # documentation and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 15 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 16 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 17 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 18 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 19 | # SUBSTITUTE GOODS OR SERVICES LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 20 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 21 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 22 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 23 | # POSSIBILITY OF SUCH DAMAGE. 24 | """ 25 | classes to help with tracking message status 26 | """ 27 | import time 28 | 29 | 30 | class SentMessage(): 31 | """ 32 | Allows recording statistics of a published message. 33 | Used internally to generate statistics for the run. 34 | """ 35 | def __init__(self, mid, real_size): 36 | self.mid = mid 37 | self.size = real_size 38 | self.received = False 39 | self.time_created = time.time() 40 | self.time_received = None 41 | 42 | def receive(self): 43 | self.received = True 44 | self.time_received = time.time() 45 | 46 | def time_flight(self): 47 | return self.time_received - self.time_created 48 | 49 | def __repr__(self): 50 | if self.received: 51 | return ("MSG(%d) OK, flight time: %f seconds" 52 | % (self.mid, self.time_flight())) 53 | else: 54 | return ("MSG(%d) INCOMPLETE in flight for %f seconds so far" 55 | % (self.mid, time.time() - self.time_created)) 56 | 57 | 58 | class ObservedMessage(): 59 | """ 60 | Allows recording statistics of a published message. 61 | Used internally to generate statistics for the run. 62 | """ 63 | def __key(self): 64 | # Yes, we only care about these. This lets us find duplicates easily 65 | # TODO - perhaps time_created could go here too? 66 | return (self.cid, self.mid) 67 | 68 | def __init__(self, msg): 69 | segments = msg.topic.split("/") 70 | self.cid = segments[1] 71 | self.mid = int(segments[3]) 72 | payload_segs = msg.payload.split(",") 73 | self.time_created = time.mktime(time.localtime(float(payload_segs[0]))) 74 | self.time_received = time.time() 75 | 76 | def time_flight(self): 77 | return self.time_received - self.time_created 78 | 79 | def __repr__(self): 80 | return ("MSG(%s:%d) OK, flight time: %f ms (c:%f, r:%f)" 81 | % (self.cid, self.mid, self.time_flight() * 1000, 82 | self.time_created, self.time_received)) 83 | 84 | def __eq__(self, y): 85 | return self.__key() == y.__key() 86 | 87 | def __hash__(self): 88 | return hash(self.__key()) 89 | -------------------------------------------------------------------------------- /beem/msgs.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013, ReMake Electric ehf 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # 7 | # 1. Redistributions of source code must retain the above copyright notice, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright 10 | # notice, this list of conditions and the following disclaimer in the 11 | # documentation and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 15 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 16 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 17 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 18 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 19 | # SUBSTITUTE GOODS OR SERVICES LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 20 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 21 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 22 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 23 | # POSSIBILITY OF SUCH DAMAGE. 24 | """ 25 | Message generator implementations 26 | """ 27 | import string 28 | import random 29 | import time 30 | 31 | 32 | def GaussianSize(cid, sequence_size, target_size): 33 | """ 34 | Message generator creating gaussian distributed message sizes 35 | centered around target_size with a deviance of target_size / 20 36 | """ 37 | num = 1 38 | while num <= sequence_size: 39 | topic = "mqtt-malaria/%s/data/%d/%d" % (cid, num, sequence_size) 40 | real_size = int(random.gauss(target_size, target_size / 20)) 41 | payload = ''.join(random.choice(string.hexdigits) for _ in range(real_size)) 42 | yield (num, topic, payload) 43 | num = num + 1 44 | 45 | 46 | def TimeTracking(generator): 47 | """ 48 | Wrap an existing generator by prepending time tracking information 49 | to the start of the payload. 50 | """ 51 | for a, b, c in generator: 52 | newpayload = "{:f},{:s}".format(time.time(), c) 53 | yield (a, b, newpayload) 54 | 55 | 56 | def RateLimited(generator, msgs_per_sec): 57 | """ 58 | Wrap an existing generator in a rate limit. 59 | This will probably behave somewhat poorly at high rates per sec, as it 60 | simply uses time.sleep(1/msg_rate) 61 | """ 62 | for x in generator: 63 | yield x 64 | time.sleep(1 / msgs_per_sec) 65 | 66 | 67 | def JitteryRateLimited(generator, msgs_per_sec, jitter=0.1): 68 | """ 69 | Wrap an existing generator in a (jittery) rate limit. 70 | This will probably behave somewhat poorly at high rates per sec, as it 71 | simply uses time.sleep(1/msg_rate) 72 | """ 73 | for x in generator: 74 | yield x 75 | desired = 1 / msgs_per_sec 76 | extra = random.uniform(-1 * jitter * desired, 1 * jitter * desired) 77 | time.sleep(desired + extra) 78 | 79 | 80 | def createGenerator(label, options, index=None): 81 | """ 82 | Handle creating an appropriate message generator based on a set of options 83 | index, if provided, will be appended to label 84 | """ 85 | cid = label 86 | if index: 87 | cid += "_" + str(index) 88 | msg_gen = GaussianSize(cid, options.msg_count, options.msg_size) 89 | if options.timing: 90 | msg_gen = TimeTracking(msg_gen) 91 | if options.msgs_per_second > 0: 92 | if options.jitter > 0: 93 | msg_gen = JitteryRateLimited(msg_gen, 94 | options.msgs_per_second, 95 | options.jitter) 96 | else: 97 | msg_gen = RateLimited(msg_gen, options.msgs_per_second) 98 | return msg_gen 99 | -------------------------------------------------------------------------------- /beem/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013, ReMake Electric ehf 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # 7 | # 1. Redistributions of source code must retain the above copyright notice, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright 10 | # notice, this list of conditions and the following disclaimer in the 11 | # documentation and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 15 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 16 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 17 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 18 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 19 | # SUBSTITUTE GOODS OR SERVICES LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 20 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 21 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 22 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 23 | # POSSIBILITY OF SUCH DAMAGE. 24 | """ 25 | Basic helper routines that might be needed in multiple places. 26 | Also, just a place holder for the package. 27 | """ 28 | import json 29 | 30 | 31 | def print_publish_stats(stats): 32 | """ 33 | pretty print a stats object that held publisher details 34 | """ 35 | if not stats.get("clientid", None): 36 | raise ValueError("Can't print stats on a non stats object?!", stats) 37 | print("Clientid: %s" % stats["clientid"]) 38 | print("Message succes rate: %.2f%% (%d/%d messages)" 39 | % (100 * stats["rate_ok"], stats["count_ok"], stats["count_total"])) 40 | print("Message timing mean %.2f ms" % stats["time_mean"]) 41 | print("Message timing stddev %.2f ms" % stats["time_stddev"]) 42 | print("Message timing min %.2f ms" % stats["time_min"]) 43 | print("Message timing max %.2f ms" % stats["time_max"]) 44 | print("Messages per second %.2f" % stats["msgs_per_sec"]) 45 | print("Total time %.2f secs" % stats["time_total"]) 46 | 47 | 48 | def json_dump_stats(stats, path): 49 | """ 50 | write the stats object to disk. 51 | """ 52 | try: 53 | with open(path, 'w') as f: 54 | json.dump(stats, f) 55 | print("Wrote stats to: {}".format(path)) 56 | except IOError: 57 | print("Couldn't dump JSON stats to: {}".format(path)) 58 | 59 | 60 | def aggregate_publish_stats(stats_set): 61 | """ 62 | For a set of per process _publish_ stats, make some basic aggregated stats 63 | timings are a simple mean of the input timings. ie the aggregate 64 | "minimum" is the average of the minimum of each process, not the 65 | absolute minimum of any process. 66 | Likewise, aggregate "stddev" is a simple mean of the stddev from each 67 | process, not an entire population stddev. 68 | """ 69 | def naive_average(the_set): 70 | return sum(the_set) / len(the_set) 71 | count_ok = sum([x["count_ok"] for x in stats_set]) 72 | count_total = sum([x["count_total"] for x in stats_set]) 73 | cid = "Aggregate stats (simple avg) for %d processes" % len(stats_set) 74 | avg_msgs_per_sec = naive_average([x["msgs_per_sec"] for x in stats_set]) 75 | return { 76 | "clientid": cid, 77 | "count_ok": count_ok, 78 | "count_total": count_total, 79 | "rate_ok": count_ok / count_total, 80 | "time_min": naive_average([x["time_min"] for x in stats_set]), 81 | "time_max": naive_average([x["time_max"] for x in stats_set]), 82 | "time_mean": naive_average([x["time_mean"] for x in stats_set]), 83 | "time_stddev": naive_average([x["time_stddev"] for x in stats_set]), 84 | "msgs_per_sec": avg_msgs_per_sec * len(stats_set) 85 | } 86 | -------------------------------------------------------------------------------- /beem/cmds/keygen.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Copyright (c) 2013, ReMake Electric ehf 4 | # All rights reserved. 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are met: 8 | # 9 | # 1. Redistributions of source code must retain the above copyright notice, 10 | # this list of conditions and the following disclaimer. 11 | # 2. Redistributions in binary form must reproduce the above copyright 12 | # notice, this list of conditions and the following disclaimer in the 13 | # documentation and/or other materials provided with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 16 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 17 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 18 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 19 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 20 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 21 | # SUBSTITUTE GOODS OR SERVICES LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 22 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 23 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 24 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 25 | # POSSIBILITY OF SUCH DAMAGE. 26 | # 27 | # This file implements the "malaria keygen" command 28 | """ 29 | Creates key files, suitable for use with mosquitto servers with tls-psk 30 | (or, maybe even with username/password or tls-srp....) 31 | """ 32 | 33 | import argparse 34 | import random 35 | import string 36 | 37 | 38 | def add_args(subparsers): 39 | parser = subparsers.add_parser( 40 | "keygen", 41 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 42 | description=__doc__, 43 | help="Create a keyfile for use with mosquitto") 44 | 45 | parser.add_argument( 46 | "-t", "--template", default="malaria-tlspsk-%d", 47 | help="""Set the template for usernames, the %%d will be replaced 48 | with a sequential number""") 49 | parser.add_argument( 50 | "-n", "--count", type=int, default=10, 51 | help="How many user/key pairs to generate") 52 | parser.add_argument( 53 | "-f", "--file", default="-", type=argparse.FileType("w"), 54 | help="File to write the generated keys to") 55 | parser.add_argument( 56 | "-F", "--infile", default="-", type=argparse.FileType("r"), 57 | help="File to read keys from") 58 | parser.add_argument( 59 | "-s", "--split", action="store_true", 60 | help="""Instead of generating 'count' keys into 'file', split file 61 | into count pieces, named file.1,2,3,4....""") 62 | 63 | parser.set_defaults(handler=run) 64 | 65 | 66 | def generate(options): 67 | with options.file as f: 68 | for i in range(options.count): 69 | user = options.template % (i + 1) 70 | key = ''.join(random.choice(string.hexdigits) for _ in range(40)) 71 | f.write("%s:%s\n" % (user, key)) 72 | 73 | 74 | def chunks(l, n): 75 | """ 76 | Yield n successive chunks from l. 77 | Source: http://stackoverflow.com/a/2130042 78 | """ 79 | newn = int(len(l) / n) 80 | for i in xrange(0, n - 1): 81 | yield l[i * newn:i * newn + newn] 82 | yield l[n * newn - newn:] 83 | 84 | 85 | def split(file_handle, count): 86 | basename = file_handle.name 87 | with file_handle as f: 88 | inputs = f.readlines() 89 | print("splitting %d inputs into %d pieces " % (len(inputs), count)) 90 | 91 | for i, lines in enumerate(chunks(inputs, count)): 92 | with open("%s.chunked.%d" % (basename, (i + 1)), "w") as f: 93 | print("writing %d entries to %s" % (len(lines), f.name)) 94 | [f.write(l) for l in lines] 95 | 96 | 97 | def run(options): 98 | if options.split: 99 | split(options.infile, options.count) 100 | else: 101 | generate(options) 102 | -------------------------------------------------------------------------------- /beem/cmds/subscribe.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Copyright (c) 2013, ReMake Electric ehf 4 | # All rights reserved. 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are met: 8 | # 9 | # 1. Redistributions of source code must retain the above copyright notice, 10 | # this list of conditions and the following disclaimer. 11 | # 2. Redistributions in binary form must reproduce the above copyright 12 | # notice, this list of conditions and the following disclaimer in the 13 | # documentation and/or other materials provided with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 16 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 17 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 18 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 19 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 20 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 21 | # SUBSTITUTE GOODS OR SERVICES LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 22 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 23 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 24 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 25 | # POSSIBILITY OF SUCH DAMAGE. 26 | # 27 | # This file implements the "malaria subscribe" command 28 | """ 29 | Listen to a stream of messages and capture statistics on their timing 30 | """ 31 | 32 | import argparse 33 | import os 34 | import beem.listen 35 | 36 | 37 | def print_stats(stats): 38 | """ 39 | pretty print a listen stats object 40 | """ 41 | print("Clientid: %s" % stats["clientid"]) 42 | print("Total clients tracked: %s" % stats["client_count"]) 43 | print("Total messages: %d" % stats["msg_count"]) 44 | print("Total time: %0.2f secs" % stats["time_total"]) 45 | print("Messages per second: %d (%f ms per message)" 46 | % (stats["msg_per_sec"], stats["ms_per_msg"])) 47 | if stats["test_complete"]: 48 | for cid, dataset in stats["msg_missing"].items(): 49 | if len(dataset) > 0: 50 | print("Messages missing for client %s: %s" % (cid, dataset)) 51 | print("Messages duplicated: %s" % stats["msg_duplicates"]) 52 | else: 53 | print("Test aborted, unable to gather duplicate/missing stats") 54 | print("Flight time mean: %0.2f ms" % (stats["flight_time_mean"] * 1000)) 55 | print("Flight time stddev: %0.2f ms" % (stats["flight_time_stddev"] * 1000)) 56 | print("Flight time min: %0.2f ms" % (stats["flight_time_min"] * 1000)) 57 | print("Flight time max: %0.2f ms" % (stats["flight_time_max"] * 1000)) 58 | 59 | 60 | def add_args(subparsers): 61 | parser = subparsers.add_parser( 62 | "subscribe", 63 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 64 | description=__doc__, 65 | help="Listen to a stream of messages") 66 | 67 | parser.add_argument( 68 | "-c", "--clientid", default="beem.listr-%d" % os.getpid(), 69 | help="""Set the client id of the listner, can be useful for acls 70 | Default has pid information appended. 71 | """) 72 | parser.add_argument( 73 | "-H", "--host", default="localhost", 74 | help="MQTT host to connect to") 75 | parser.add_argument( 76 | "-p", "--port", type=int, default=1883, 77 | help="Port for remote MQTT host") 78 | parser.add_argument( 79 | "-q", "--qos", type=int, choices=[0, 1, 2], 80 | help="set the mqtt qos for subscription", default=1) 81 | parser.add_argument( 82 | "-n", "--msg_count", type=int, default=10, 83 | help="How many messages to expect") 84 | parser.add_argument( 85 | "-N", "--client_count", type=int, default=1, 86 | help="""How many clients to expect. See docs for examples 87 | of how this works""") 88 | parser.add_argument( 89 | "-t", "--topic", default="mqtt-malaria/+/data/#", 90 | help="""Topic to subscribe to, will be sorted into clients by the 91 | '+' symbol""") 92 | parser.add_argument( 93 | "--json", type=str, default=None, 94 | help="""Dump the collected stats into the given JSON file.""") 95 | 96 | parser.set_defaults(handler=run) 97 | 98 | 99 | def run(options): 100 | ts = beem.listen.TrackingListener(options.host, options.port, options) 101 | ts.run(options.qos) 102 | print_stats(ts.stats()) 103 | if options.json is not None: 104 | beem.json_dump_stats(ts.stats(), options.json) 105 | -------------------------------------------------------------------------------- /beem/load.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013, ReMake Electric ehf 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # 7 | # 1. Redistributions of source code must retain the above copyright notice, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright 10 | # notice, this list of conditions and the following disclaimer in the 11 | # documentation and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 15 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 16 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 17 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 18 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 19 | # SUBSTITUTE GOODS OR SERVICES LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 20 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 21 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 22 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 23 | # POSSIBILITY OF SUCH DAMAGE. 24 | """ 25 | This is a module for a single message publishing process. 26 | It is capable of generating a stream of messages and collecting timing 27 | statistics on the results of publishing that stream. 28 | """ 29 | 30 | from __future__ import division 31 | 32 | import logging 33 | import math 34 | import time 35 | 36 | import paho.mqtt.client as mqtt 37 | 38 | from beem.trackers import SentMessage as MsgStatus 39 | 40 | 41 | class TrackingSender(): 42 | """ 43 | An MQTT message publisher that tracks time to ack publishes 44 | 45 | functions make_topic(sequence_num) and make_payload(sequence_num, size) 46 | can be provided to help steer message generation 47 | 48 | The timing of the publish calls are tracked for analysis 49 | 50 | This is a _single_ publisher, it's not a huge load testing thing by itself. 51 | 52 | Example: 53 | cid = "Test-clientid-%d" % os.getpid() 54 | ts = TrackingSender("mqtt.example.org", 1883, cid) 55 | generator = beem.msgs.GaussianSize(cid, 100, 1024) 56 | ts.run(generator, qos=1) 57 | stats = ts.stats() 58 | print(stats["rate_ok"]) 59 | print(stats["time_stddev"]) 60 | """ 61 | msg_statuses = {} 62 | 63 | def __init__(self, host, port, cid): 64 | self.cid = cid 65 | self.log = logging.getLogger(__name__ + ":" + cid) 66 | self.mqttc = mqtt.Client(cid) 67 | self.mqttc.on_publish = self.publish_handler 68 | # TODO - you _probably_ want to tweak this 69 | if hasattr(self.mqttc, "max_inflight_messages_set"): 70 | self.mqttc.max_inflight_messages_set(200) 71 | rc = self.mqttc.connect(host, port, 60) 72 | if rc: 73 | raise Exception("Couldn't even connect! ouch! rc=%d" % rc) 74 | # umm, how? 75 | self.mqttc.loop_start() 76 | 77 | def publish_handler(self, mosq, userdata, mid): 78 | self.log.debug("Received confirmation of mid %d", mid) 79 | handle = self.msg_statuses.get(mid, None) 80 | while not handle: 81 | self.log.warn("Received a publish for mid: %d before we saved its creation", mid) 82 | time.sleep(0.5) 83 | handle = self.msg_statuses.get(mid, None) 84 | handle.receive() 85 | 86 | def run(self, msg_generator, qos=1): 87 | """ 88 | Start a (long lived) process publishing messages 89 | from the provided generator at the requested qos 90 | 91 | This process blocks until _all_ published messages have been acked by 92 | the publishing library. 93 | """ 94 | publish_count = 0 95 | self.time_start = time.time() 96 | for _, topic, payload in msg_generator: 97 | result, mid = self.mqttc.publish(topic, payload, qos) 98 | assert(result == 0) 99 | self.msg_statuses[mid] = MsgStatus(mid, len(payload)) 100 | publish_count += 1 101 | self.log.info("Finished publish %d msgs at qos %d", publish_count, qos) 102 | 103 | finished = False 104 | while not finished: 105 | missing = [x for x in self.msg_statuses.values() if not x.received] 106 | finished = len(missing) == 0 107 | if finished: 108 | break 109 | mc = len(missing) 110 | self.log.info("Still waiting for %d messages to be confirmed.", mc) 111 | time.sleep(2) # This is too long for short tests. 112 | for x in missing: 113 | self.log.debug(x) 114 | # FIXME - needs an escape clause here for giving up on messages? 115 | self.time_end = time.time() 116 | self.mqttc.loop_stop() 117 | time.sleep(1) 118 | self.mqttc.disconnect() 119 | 120 | def stats(self): 121 | """ 122 | Generate a set of statistics for the set of message responses. 123 | count, success rate, min/max/mean/stddev are all generated. 124 | """ 125 | successful = [x for x in self.msg_statuses.values() if x.received] 126 | rate = len(successful) / len(self.msg_statuses) 127 | # Let's work in milliseconds now 128 | times = [x.time_flight() * 1000 for x in successful] 129 | mean = sum(times) / len(times) 130 | squares = [x * x for x in [q - mean for q in times]] 131 | stddev = math.sqrt(sum(squares) / len(times)) 132 | return { 133 | "clientid": self.cid, 134 | "count_ok": len(successful), 135 | "count_total": len(self.msg_statuses), 136 | "rate_ok": rate, 137 | "time_mean": mean, 138 | "time_min": min(times), 139 | "time_max": max(times), 140 | "time_stddev": stddev, 141 | "msgs_per_sec": len(successful) / (self.time_end - self.time_start), 142 | "time_total": self.time_end - self.time_start 143 | } 144 | -------------------------------------------------------------------------------- /README-swarm.md: -------------------------------------------------------------------------------- 1 | Running malaria in a swarm 2 | ========================== 3 | 4 | malaria includes [fabric](http://docs.fabfile.org/) scripts to help automate 5 | running the command line utilities on multiple hosts. This has seen use with 6 | both [vagrant](http://www.vagrantup.com/), and also Amazone EC2 instances 7 | 8 | Notes: 9 | * attack nodes are only tested on ubuntu 1204 machines at this point 10 | * To use the vagrant setup, you need https://github.com/ronnix/fabtools/pull/177 11 | 12 | Fabric doesn't/can't provide as much help on the command line as the python 13 | scripts can, so here's a basic overview 14 | 15 | Get malaria 16 | =========== 17 | You should get a clone of malaria locally first. 18 | 19 | git clone https://github.com/etactica/mqtt-malaria.git 20 | 21 | To use fabric, you either need to install fabric and all the ec2 tools 22 | yourself, or, make a python virtualenv and install malaria into it. This 23 | will install all the dependencies you need 24 | 25 | ``` 26 | virtualenv .env 27 | . .env/bin/activate 28 | pip install -e . 29 | fab -l # list all available fab commands 30 | fab -d beeup # get documentation on the beeup command 31 | ``` 32 | 33 | 34 | There are two main tools that can be used for watching an attack, 35 | malaria subscribe and malaria watch. 36 | 37 | Use malaria to watch an attack (new hotness method) 38 | ================================================== 39 | "malaria subscribe" is a useful tool, but it relies heavily on knowing what 40 | pattern of traffic is being sent. 41 | 42 | "malaria watch" on the other hand is much more passive, and suitable for just 43 | leaving running. It collects less stats, but probably ones more interesting 44 | to a load tester, and with less configuration. There's no fab support for 45 | this yet, you ssh to the target and run it there. Also, note that it's not 46 | designed to run by itself. It's used in conjunction with somthing like 47 | [vmstatplot](https://github.com/etactica/VmstatPlot) to collect 48 | statistics. 49 | 50 | Deploy malaria to the target as usual and then start monitoring... 51 | 52 | ``` 53 | fab -H target.machine cleanup deploy 54 | ssh target.machine 55 | cd /tmp 56 | mkdir /tmp/mqttfs 57 | . $(cat malaria-tmp-homedir)/venv/bin/activate 58 | (venv)karlp@target.machine:/tmp/malaria-tmp-XXXX$ malaria watch -t "#" -d /tmp/mqttfs 59 | ``` 60 | 61 | This creates a pseudo file system with some statics files in it, this is a 62 | lot like the way linux's /proc file system works. 63 | 64 | Sidebar - Using vmstatplot 65 | ========================= 66 | vmstatplot is a graphing wrapper around "vmstat" that includes the contents of 67 | some files, like the virtual files in the mqttfs directory we made above. 68 | You should mostly read the README it provides, but basically, you start it, 69 | and then run collect every now and again to make a graph. 70 | 71 | 72 | Use malaria to watch an attack (old busted method) 73 | ============================== 74 | "malaria subscribe" is one command line utility for watching the 75 | messages published and collecting stats. This takes a lot of cpu, but it 76 | collects stats on flight time, duples, missing and so on. This also needs 77 | to know the exact parameters of the attack, so it knows what to expect. 78 | 79 | I've since found this to be not super useful, it's more useful for 80 | constrained testing on a local machine, rather than long term load testing. 81 | 82 | *note* you may wish to run this locally, connecting to the remote target 83 | like so *UPDATE THIS with more real world experience* 84 | 85 | ./malaria -H attack_target -x -y -z 86 | 87 | This may be a reduced cpu load on the target, but will use network bandwidth instead 88 | 89 | ### Install malaria software on the target 90 | 91 | fab -H attack_target deploy 92 | 93 | ### Run the listener 94 | 95 | Be prepared to enter a malaria subscribe command appropriate to your warhead 96 | (See below) and number of attack nodes. 97 | Any commands you enter are executed on the remote host, inside a virtualenv 98 | created for the observer. Use Ctrl-C to exit the command prompt 99 | 100 | fab -H attack_target observe 101 | 102 | 103 | ### Remove malaria from the target 104 | 105 | fab -H attack_target cleanup 106 | 107 | Setup AWS Attack Nodes 108 | ========================== 109 | "boto" is the python library for interacting with ec2. 110 | 111 | Make a ~/.boto file like so 112 | ``` 113 | [Credentials] 114 | aws_access_key_id = GET_THESE_FROM_ 115 | aws_secret_access_key = _YOUR_AWS_CONSOLE_WEBPAGE 116 | ``` 117 | If you don't know the secret part, you'll need to make a new credential, but 118 | that's something for you to work out! 119 | 120 | Setup malaria on all attack nodes 121 | ``` 122 | # Turn on 5 bees in eu-west-1 (see fab -d beeup for other options) 123 | fab beeup:5,key_name=blahblahblah 124 | # run apt-get update on all of them 125 | fab -i path_to_blahblahblah.pem mstate aptup 126 | # Install all dependencies in parallel 127 | fab -i path_to_blahblahblah.pem mstate everybody:True 128 | # deploy malaria code itself (serial unfortunately, help wanted!) 129 | fab -i path_to_blahblahblah.pem mstate deploy 130 | # If using bridging and tls-psk, generate/split your keyfile amongst all attack nodes 131 | malaria keygen -n 20000 > malaria.pskfile 132 | fab -i path_to_blahblahblah.pem mstate share_key:malaria.pskfile 133 | ``` 134 | 135 | 136 | Use malaria to attack (AWS Nodes) 137 | ================================= 138 | 139 | Choose a warhead. Warheads are basically command scripts that are executed on 140 | each of your nodes. Normally, the warhead runs one of the general malaria 141 | publish commands that you can also run from your local clone of the malaria 142 | repository. An example warhead is 143 | ``` 144 | # 100 clients at 1 mps, 500 bytes, for 1000 mesages 145 | malaria publish -p 8883 -b --psk_file /tmp/malaria-tmp-keyfile -P 100 -n 1000 -T 1 -s 500 -t -H %(malaria_target)s 146 | ``` 147 | 148 | This runs 100 clients on _each_ of your attack nodes. So with 10 worker bees, 149 | this will make 1000 clients, each publishing at 1 message per second. 150 | 151 | With a warhead chosen, run the attack... 152 | ``` 153 | fab -i path_to_blahblahblah.pem mstate attack:target.machine.name,warhead=path_to_warhead_file 154 | ``` 155 | 156 | This may take a long time, of course. If you'd like to abort a test, pressing 157 | ctrl-c on the fabric script will often leave things running on the far side. 158 | The fab script includes a target that will abort any running malaria/mosquitto 159 | instances on the worker bees. 160 | 161 | ``` 162 | fab -i path_to_blahblahblah.pem mstate abort 163 | ``` 164 | 165 | That's it for attacking. To shut down your AWS bees, (terminate them) 166 | ``` 167 | fab -i path_to_blahblahblah.pem mstate down 168 | ``` 169 | 170 | Technical notes 171 | =============== 172 | The "mstate" target works by saving all the information about created worker 173 | bees in the ~/.malaria file. fab down removes this. This is why you don't 174 | need to specify all the hosts each time. 175 | 176 | Use malaria to attack (Vagrant nodes) 177 | ==================================== 178 | Below are a set of commands suitable for use with either Vagrant boxes 179 | or with "real" hosts created externally. 180 | 181 | Depending on your ssh key/password options, you may need extra options 182 | to "fab" 183 | 184 | ### Optionally, Create & setup two vagrant virtual machines to be attack nodes 185 | 186 | vagrant up 187 | 188 | ### Install malaria software on each node and prepare to attack 189 | 190 | fab vagrant up 191 | or 192 | fab -H attack-node1.example.org,attack-node2.example.org,... up 193 | 194 | ### Instruct all nodes from the "up" phase to attack a target with a warhead. 195 | You can run multiple attacks after "up" 196 | 197 | fab -i /home/karlp/.vagrant.d/insecure_private_key mstate attack:mqtt-target.example.org,warhead=warheads/complex_10x10-bursty-double_publish.warhead 198 | fab attack:mqtt-target.example.org 199 | 200 | ### Stop and remove all malaria code from attack nodes 201 | 202 | fab -i /home/karlp/.vagrant.d/insecure_private_key down 203 | fab down 204 | 205 | ### Optionally, Destroy and turn off the vagrant virtual machines created 206 | 207 | vagrant destroy 208 | 209 | -------------------------------------------------------------------------------- /beem/bridge.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013, ReMake Electric ehf 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # 7 | # 1. Redistributions of source code must retain the above copyright notice, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright 10 | # notice, this list of conditions and the following disclaimer in the 11 | # documentation and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 15 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 16 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 17 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 18 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 19 | # SUBSTITUTE GOODS OR SERVICES LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 20 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 21 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 22 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 23 | # POSSIBILITY OF SUCH DAMAGE. 24 | """ 25 | This is a module for a single message publishing process, 26 | that publishes to it's own private bridge. The _bridge_ is configured 27 | to bridge out to the designated target. 28 | """ 29 | 30 | from __future__ import division 31 | 32 | import logging 33 | import os 34 | import socket 35 | import subprocess 36 | import tempfile 37 | import threading 38 | import time 39 | 40 | import beem.load 41 | import beem.msgs 42 | 43 | MOSQ_BRIDGE_CFG_TEMPLATE = """ 44 | log_dest topic 45 | #log_dest stdout 46 | bind_address 127.0.0.1 47 | port %(listen_port)d 48 | 49 | connection mal-bridge-%(cid)s 50 | address %(malaria_target)s 51 | topic mqtt-malaria/# out %(qos)d 52 | """ 53 | 54 | MOSQ_BRIDGE_CFG_TEMPLATE_PSK = """ 55 | bridge_identity %(psk_id)s 56 | bridge_psk %(psk_key)s 57 | bridge_tls_version tlsv1 58 | """ 59 | 60 | 61 | class BridgingSender(): 62 | """ 63 | A MQTT message publisher that publishes to it's own personal bridge 64 | """ 65 | def __init__(self, target_host, target_port, cid, auth=None): 66 | self.cid = cid 67 | self.auth = auth 68 | self.log = logging.getLogger(__name__ + ":" + cid) 69 | 70 | self.mb = MosquittoBridgeBroker(target_host, target_port, cid, auth) 71 | 72 | def run(self, generator, qos=1): 73 | with self.mb as mb: 74 | launched = False 75 | while not launched: 76 | try: 77 | self.ts = beem.load.TrackingSender("localhost", mb.port, "ts_" + mb.label) 78 | launched = True 79 | except: 80 | # TrackingSender fails if it can't connect 81 | time.sleep(0.5) 82 | self.ts.run(generator, qos) 83 | 84 | def stats(self): 85 | return self.ts.stats() 86 | 87 | 88 | class _ThreadedBridgeWorker(threading.Thread): 89 | def __init__(self, mb, options): 90 | threading.Thread.__init__(self) 91 | self.mb = mb 92 | self.options = options 93 | 94 | def run(self): 95 | with self.mb as mb: 96 | launched = False 97 | while not launched: 98 | try: 99 | ts = beem.load.TrackingSender("localhost", mb.port, "ts_" + mb.label) 100 | launched = True 101 | except: 102 | # TrackingSender fails if it can't connect 103 | time.sleep(0.5) 104 | 105 | # This is probably what you want for psk setups with ACLs 106 | if self.mb.auth: 107 | cid = self.mb.auth.split(":")[0] 108 | gen = beem.msgs.createGenerator(cid, self.options) 109 | else: 110 | gen = beem.msgs.createGenerator(self.mb.label, self.options) 111 | ts.run(gen) 112 | self.stats = ts.stats() 113 | 114 | 115 | class ThreadedBridgingSender(): 116 | """ 117 | A MQTT message publisher that publishes to it's own personal bridge, 118 | unlike BridgingSender, this fires up X brokers, and X threads to publish. 119 | This _can_ be much softer on memory usage, and as long as the per thread 120 | message rate stays low enough, and the ratio not too unreasonable, there 121 | should be no performance problems 122 | """ 123 | 124 | def __init__(self, options, proc_num, auth=None): 125 | """ 126 | target_host and target_port are used as is 127 | cid is used as cid_%d where the thread number is inserted 128 | auth should be an array of "identity:key" strings, of the same size as 129 | ratio 130 | """ 131 | self.options = options 132 | self.cid_base = options.clientid 133 | self.auth = auth 134 | self.ratio = options.thread_ratio 135 | self.mosqs = [] 136 | if auth: 137 | assert len(auth) == self.ratio 138 | self.log = logging.getLogger(__name__ + ":" + self.cid_base) 139 | 140 | # Create all the config files immediately 141 | for x in range(self.ratio): 142 | label = "%s_%d_%d" % (self.cid_base, proc_num, x) 143 | mb = MosquittoBridgeBroker(options.host, 144 | options.port, 145 | label) 146 | if auth: 147 | mb.auth = auth[x].strip() 148 | self.mosqs.append(mb) 149 | 150 | def run(self): 151 | worker_threads = [] 152 | for mb in self.mosqs: 153 | t = _ThreadedBridgeWorker(mb, self.options) 154 | t.start() 155 | worker_threads.append(t) 156 | 157 | # Wait for all threads to complete 158 | self.stats = [] 159 | for t in worker_threads: 160 | t.join() 161 | self.stats.append(t.stats) 162 | self.log.debug("stats were %s", t.stats) 163 | 164 | 165 | class MosquittoBridgeBroker(): 166 | """ 167 | Runs an external mosquitto process configured to bridge to the target 168 | host/port, optionally with tls-psk. 169 | 170 | use this with a context manager to start/stop the broker automatically 171 | 172 | mm = MosquittoBridgeBroker(host, port, "my connection label", 173 | "psk_identity:psk_key") 174 | with mm as b: 175 | post_messages_to_broker(b.port) 176 | 177 | """ 178 | 179 | def _get_free_listen_port(self): 180 | """ 181 | Find a free local TCP port that we can listen on, 182 | we want this to be able to give to mosquitto. 183 | """ 184 | # python 2.x doesn't have __enter__ and __exit__ on socket objects 185 | # so can't use with: clauses 186 | # Yes, there is a race condition between closing the socket and 187 | # starting mosquitto. 188 | s = socket.socket() 189 | s.bind(("localhost", 0)) 190 | chosen_port = s.getsockname()[1] 191 | s.close() 192 | return chosen_port 193 | 194 | def _make_config(self): 195 | """ 196 | Make an appropriate mosquitto config snippet out of 197 | our params and saved state 198 | """ 199 | self.port = self._get_free_listen_port() 200 | template = MOSQ_BRIDGE_CFG_TEMPLATE 201 | inputs = { 202 | "listen_port": self.port, 203 | "malaria_target": "%s:%d" % (self.target_host, self.target_port), 204 | "cid": self.label, 205 | "qos": 1 206 | } 207 | if self.auth: 208 | template = template + MOSQ_BRIDGE_CFG_TEMPLATE_PSK 209 | aa = self.auth.split(":") 210 | inputs["psk_id"] = aa[0] 211 | inputs["psk_key"] = aa[1] 212 | 213 | return template % inputs 214 | 215 | def __init__(self, target_host, target_port, label=None, auth=None): 216 | self.target_host = target_host 217 | self.target_port = target_port 218 | self.label = label 219 | self.auth = auth 220 | self.log = logging.getLogger(__name__ + "_" + label) 221 | 222 | def __enter__(self): 223 | conf = self._make_config() 224 | # Save it to a temporary file 225 | self._f = tempfile.NamedTemporaryFile(delete=False) 226 | self._f.write(conf) 227 | self._f.close() 228 | self.log.debug("Creating config file %s: <%s>", self._f.name, conf) 229 | 230 | # too important, even though _f.close() has finished :( 231 | time.sleep(1) 232 | args = ["mosquitto", "-c", self._f.name] 233 | self._mosq = subprocess.Popen(args) 234 | return self 235 | 236 | def __exit__(self, exc_type, exc_value, traceback): 237 | self.log.debug("Swatting mosquitto on exit") 238 | os.unlink(self._f.name) 239 | # Attempt to let messages still get out of the broker... 240 | time.sleep(2) 241 | self._mosq.terminate() 242 | self._mosq.wait() 243 | 244 | 245 | if __name__ == "__main__": 246 | import sys 247 | logging.basicConfig(level=logging.INFO, stream=sys.stderr) 248 | b = BridgingSender("localhost", 1883, "hohoho") 249 | # b = BridgingSender("localhost", 8883, "hohoho", "karlos:01230123") 250 | generator = beem.msgs.GaussianSize("karlos", 10, 100) 251 | b.run(generator, 1) 252 | 253 | # b = ThreadedBridgingSender("localhost", 1883, "hohoho", ratio=20) 254 | # generator = beem.msgs.GaussianSize 255 | # generator_args = ("karlos", 10, 100) 256 | # b.run(generator, generator_args, 1) 257 | -------------------------------------------------------------------------------- /beem/cmds/publish.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Copyright (c) 2013, ReMake Electric ehf 4 | # All rights reserved. 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are met: 8 | # 9 | # 1. Redistributions of source code must retain the above copyright notice, 10 | # this list of conditions and the following disclaimer. 11 | # 2. Redistributions in binary form must reproduce the above copyright 12 | # notice, this list of conditions and the following disclaimer in the 13 | # documentation and/or other materials provided with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 16 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 17 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 18 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 19 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 20 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 21 | # SUBSTITUTE GOODS OR SERVICES LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 22 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 23 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 24 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 25 | # POSSIBILITY OF SUCH DAMAGE. 26 | # 27 | # This file implements the "malaria publish" command 28 | """ 29 | Publish a stream of messages and capture statistics on their timing. 30 | """ 31 | 32 | import argparse 33 | import multiprocessing 34 | import os 35 | import random 36 | import socket 37 | import time 38 | 39 | import beem.load 40 | import beem.bridge 41 | import beem.msgs 42 | 43 | 44 | def my_custom_msg_generator(sequence_length): 45 | """ 46 | An example of a custom msg generator. 47 | 48 | You must return a tuple of sequence number, topic and payload 49 | on each iteration. 50 | """ 51 | seq = 0 52 | while seq < sequence_length: 53 | yield (seq, "magic_topic", "very boring payload") 54 | seq += 1 55 | 56 | 57 | def _worker(options, proc_num, auth=None): 58 | """ 59 | Wrapper to run a test and push the results back onto a queue. 60 | Modify this to provide custom message generation routines. 61 | """ 62 | # Make a new clientid with our worker process number 63 | cid = "%s-%d" % (options.clientid, proc_num) 64 | if options.bridge: 65 | ts = beem.bridge.BridgingSender(options.host, options.port, cid, auth) 66 | # This is _probably_ what you want if you are specifying a key file 67 | # This would correspond with using ids as clientids, and acls 68 | if auth: 69 | cid = auth.split(":")[0] 70 | else: 71 | # FIXME - add auth support here too dummy! 72 | ts = beem.load.TrackingSender(options.host, options.port, cid) 73 | 74 | # Provide a custom generator 75 | #msg_gen = my_custom_msg_generator(options.msg_count) 76 | msg_gen = beem.msgs.createGenerator(cid, options) 77 | # This helps introduce jitter so you don't have many threads all in sync 78 | time.sleep(random.uniform(1, 10)) 79 | ts.run(msg_gen, qos=options.qos) 80 | return ts.stats() 81 | 82 | 83 | def _worker_threaded(options, proc_num, auth=None): 84 | ts = beem.bridge.ThreadedBridgingSender(options, proc_num, auth) 85 | ts.run() 86 | return ts.stats 87 | 88 | 89 | def add_args(subparsers): 90 | parser = subparsers.add_parser( 91 | "publish", 92 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 93 | description=__doc__, 94 | help="Publish a stream of messages") 95 | 96 | parser.add_argument( 97 | "-c", "--clientid", 98 | default="beem.loadr-%s-%d" % (socket.gethostname(), os.getpid()), 99 | help="""Set the client id of the publisher, can be useful for acls. 100 | Default includes host and pid information, unless a keyfile was 101 | specified, in which case the "user/identity" part is used as the 102 | client id. The clientid is also used in the default topics. 103 | """) 104 | parser.add_argument( 105 | "-H", "--host", default="localhost", 106 | help="MQTT host to connect to") 107 | parser.add_argument( 108 | "-p", "--port", type=int, default=1883, 109 | help="Port for remote MQTT host") 110 | parser.add_argument( 111 | "-q", "--qos", type=int, choices=[0, 1, 2], 112 | help="set the mqtt qos for messages published", default=1) 113 | parser.add_argument( 114 | "-n", "--msg_count", type=int, default=10, 115 | help="How many messages to send") 116 | parser.add_argument( 117 | "-s", "--msg_size", type=int, default=100, 118 | help="Size of messages to send. This will be gaussian at (x, x/20)") 119 | parser.add_argument( 120 | "-t", "--timing", action="store_true", 121 | help="""Message bodies will contain timing information instead of 122 | random hex characters. This can be combined with --msg-size option""") 123 | parser.add_argument( 124 | "-T", "--msgs_per_second", type=float, default=0, 125 | help="""Each publisher should target sending this many msgs per second, 126 | useful for simulating real devices.""") 127 | parser.add_argument( 128 | "--jitter", type=float, default=0.1, 129 | help="""Percentage jitter to use when rate limiting via --msgs_per_sec, 130 | Can/may help avoid processes sawtoothing and becoming synchronized""") 131 | parser.add_argument( 132 | "-P", "--processes", type=int, default=1, 133 | help="How many separate processes to spin up (multiprocessing)") 134 | parser.add_argument( 135 | "--thread_ratio", type=int, default=1, 136 | help="Threads per process (bridged multiprocessing) WARNING! VERY ALPHA!") 137 | 138 | parser.add_argument( 139 | "-b", "--bridge", action="store_true", 140 | help="""Instead of connecting directly to the target, fire up a 141 | separate mosquitto instance configured to bridge to the target""") 142 | # See http://stackoverflow.com/questions/4114996/python-argparse-nargs-or-depending-on-prior-argument 143 | # we shouldn't allow psk-file without bridging, as python doesn't let us use psk 144 | parser.add_argument( 145 | "--psk_file", type=argparse.FileType("r"), 146 | help="""A file of psk 'identity:key' pairs, as you would pass to 147 | mosquitto's psk_file configuration option. Each process will use a single 148 | line from the file. Only as many processes will be made as there are keys""") 149 | parser.add_argument( 150 | "--json", type=str, default=None, 151 | help="""Dump the collected stats into the given JSON file.""") 152 | 153 | parser.set_defaults(handler=run) 154 | 155 | 156 | def run(options): 157 | time_start = time.time() 158 | # This should be pretty easy to use for passwords as well as PSK.... 159 | if options.psk_file: 160 | assert options.bridge, "PSK is only supported with bridging due to python limitations, sorry about that" 161 | auth_pairs = options.psk_file.readlines() 162 | # Can only fire up as many processes as we have keys! 163 | # FIXME - not true with threading!! 164 | assert (options.thread_ratio * options.processes) <= len(auth_pairs), "can't handle more threads*procs than keys!" 165 | options.processes = min(options.processes, len(auth_pairs)) 166 | print("Using first %d keys from: %s" 167 | % (options.processes, options.psk_file.name)) 168 | pool = multiprocessing.Pool(processes=options.processes) 169 | if options.thread_ratio == 1: 170 | auth_pairs = auth_pairs[:options.processes] 171 | result_set = [pool.apply_async(_worker, (options, x, auth.strip())) for x, auth in enumerate(auth_pairs)] 172 | else: 173 | # need to slice auth_pairs up into thread_ratio sized chunks for each one. 174 | result_set = [] 175 | for x in range(options.processes): 176 | ll = options.thread_ratio 177 | keyset = auth_pairs[x*ll:x*ll + options.thread_ratio] 178 | print("process number: %d using keyset: %s" % (x, keyset)) 179 | result_set.append(pool.apply_async(_worker_threaded, (options, x, keyset))) 180 | else: 181 | pool = multiprocessing.Pool(processes=options.processes) 182 | if options.thread_ratio == 1: 183 | result_set = [pool.apply_async(_worker, (options, x)) for x in range(options.processes)] 184 | else: 185 | result_set = [pool.apply_async(_worker_threaded, (options, x)) for x in range(options.processes)] 186 | 187 | completed_set = [] 188 | while len(completed_set) < options.processes: 189 | hold_set = [] 190 | for result in result_set: 191 | if result.ready(): 192 | completed_set.append(result) 193 | else: 194 | hold_set.append(result) 195 | result_set = hold_set 196 | print("Completed workers: %d/%d" 197 | % (len(completed_set), options.processes)) 198 | if len(result_set) > 0: 199 | time.sleep(1) 200 | 201 | time_end = time.time() 202 | stats_set = [] 203 | for result in completed_set: 204 | s = result.get() 205 | if options.thread_ratio == 1: 206 | beem.print_publish_stats(s) 207 | stats_set.append(s) 208 | 209 | if options.thread_ratio == 1: 210 | agg_stats = beem.aggregate_publish_stats(stats_set) 211 | agg_stats["time_total"] = time_end - time_start 212 | beem.print_publish_stats(agg_stats) 213 | if options.json is not None: 214 | beem.json_dump_stats(agg_stats, options.json) 215 | else: 216 | agg_stats_set = [beem.aggregate_publish_stats(x) for x in stats_set] 217 | for x in agg_stats_set: 218 | x["time_total"] = time_end - time_start 219 | [beem.print_publish_stats(x) for x in agg_stats_set] 220 | if options.json is not None: 221 | beem.json_dump_stats(agg_stats_set, options.json) 222 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A set of tools to help with testing the scalability and load behaviour 2 | of MQTT environments. 3 | 4 | Status 5 | ====== 6 | 7 | This project has not been used internally since around 2015 and is not in 8 | active development. We will attempt to review and merge bugfixes, but 9 | any large feature work we simply don't have time to help with. 10 | 11 | At the time of writing, we are not aware of any forks that you should 12 | track instead, nor have we kept up with the state of the art in this field. 13 | 14 | Install 15 | ======= 16 | 17 | Requires python2, paho-mqtt python library 1.1 or greater, and fusepy. 18 | 19 | ``` 20 | virtualenv .env 21 | . .env/bin/activate 22 | pip install . 23 | ``` 24 | 25 | The tools are in multiple layers: 26 | 27 | * Python modules for message generation and sending/receiving 28 | (Documented as pydoc) 29 | * command line tools for sending/receiving with stats on a single host 30 | (Documented here) 31 | * fabric scripts for running those tools on multiple hosts 32 | (Documented in README-swarm.md) 33 | 34 | malaria publish 35 | =============== 36 | The publisher can mimic multiple separate clients, publishing messages of 37 | a known size, or with time of flight tracking information, at a known rate, 38 | or simply as fast as possible. 39 | 40 | Note, for higher values of "processes" you _will_ need to modify ulimit 41 | settings! 42 | 43 | The messages themselves are provided by generators that can be easily 44 | plugged in, see beem.msgs 45 | 46 | 47 | ``` 48 | usage: malaria publish [-h] [-c CLIENTID] [-H HOST] [-p PORT] 49 | [-q {0,1,2}] [-n MSG_COUNT] [-s MSG_SIZE] [-t] 50 | [-T MSGS_PER_SECOND] [-P PROCESSES] 51 | 52 | Publish a stream of messages and capture statistics on their timing 53 | 54 | optional arguments: 55 | -h, --help show this help message and exit 56 | -c CLIENTID, --clientid CLIENTID 57 | Set the client id of the publisher, can be useful for 58 | acls Default has pid information appended (default: 59 | beem.loadr-8015) 60 | -H HOST, --host HOST MQTT host to connect to (default: localhost) 61 | -p PORT, --port PORT Port for remote MQTT host (default: 1883) 62 | -q {0,1,2}, --qos {0,1,2} 63 | set the mqtt qos for messages published (default: 1) 64 | -n MSG_COUNT, --msg_count MSG_COUNT 65 | How many messages to send (default: 10) 66 | -s MSG_SIZE, --msg_size MSG_SIZE 67 | Size of messages to send. This will be gaussian at (x, 68 | x/20) (default: 100) 69 | -t, --timing Message bodies will contain timing information instead 70 | of random hex characters. This overrides the --msg- 71 | size option, obviously (default: False) 72 | -T MSGS_PER_SECOND, --msgs_per_second MSGS_PER_SECOND 73 | Each publisher should target sending this many msgs 74 | per second, useful for simulating real devices. 75 | (default: 0) 76 | -P PROCESSES, --processes PROCESSES 77 | How many separate processes to spin up 78 | (multiprocessing) (default: 1) 79 | ``` 80 | 81 | Examples 82 | -------- 83 | 84 | To fire up 8 processes each sending 10000 messages of ~100 bytes each, 85 | sending as fast as the code allows. 86 | ``` 87 | malaria publish -P 8 -n 10000 -H mqtt.example.org -s 100 88 | ``` 89 | 90 | To fire up 500 processes, each sending 5 messages per second, each sending 91 | 1000 messages, with time in flight tracking information 92 | ``` 93 | malaria publish -t -n 1000 -P 500 -T 5 94 | ``` 95 | 96 | Example output 97 | ``` 98 | $ ./malaria publish -t -n 100 -P 4 99 | Still waiting for results from 4 process(es) 100 | INFO:beem.load:beem.loadr-9850-1:Finished publish 100 msgs at qos 1 101 | INFO:beem.load:beem.loadr-9850-1:Waiting for 100 messages to be confirmed still... 102 | Still waiting for results from 4 process(es) 103 | INFO:beem.load:beem.loadr-9850-0:Finished publish 100 msgs at qos 1 104 | INFO:beem.load:beem.loadr-9850-0:Waiting for 100 messages to be confirmed still... 105 | INFO:beem.load:beem.loadr-9850-2:Finished publish 100 msgs at qos 1 106 | INFO:beem.load:beem.loadr-9850-2:Waiting for 100 messages to be confirmed still... 107 | Still waiting for results from 4 process(es) 108 | INFO:beem.load:beem.loadr-9850-3:Finished publish 100 msgs at qos 1 109 | INFO:beem.load:beem.loadr-9850-3:Waiting for 100 messages to be confirmed still... 110 | Still waiting for results from 4 process(es) 111 | Still waiting for results from 4 process(es) 112 | Still waiting for results from 4 process(es) 113 | Clientid: beem.loadr-9850-0 114 | Message succes rate: 100.00% (100/100 messages) 115 | Message timing mean 298.03 ms 116 | Message timing stddev 13.85 ms 117 | Message timing min 267.18 ms 118 | Message timing max 304.76 ms 119 | Messages per second 49.78 120 | Total time 2.01 secs 121 | Clientid: beem.loadr-9850-1 122 | Message succes rate: 100.00% (100/100 messages) 123 | Message timing mean 915.68 ms 124 | Message timing stddev 15.22 ms 125 | Message timing min 883.67 ms 126 | Message timing max 924.15 ms 127 | Messages per second 49.81 128 | Total time 2.01 secs 129 | Clientid: beem.loadr-9850-2 130 | Message succes rate: 100.00% (100/100 messages) 131 | Message timing mean 95.74 ms 132 | Message timing stddev 15.99 ms 133 | Message timing min 65.17 ms 134 | Message timing max 104.73 ms 135 | Messages per second 49.77 136 | Total time 2.01 secs 137 | Clientid: beem.loadr-9850-3 138 | Message succes rate: 100.00% (100/100 messages) 139 | Message timing mean 731.22 ms 140 | Message timing stddev 18.25 ms 141 | Message timing min 711.27 ms 142 | Message timing max 748.39 ms 143 | Messages per second 49.77 144 | Total time 2.01 secs 145 | Clientid: Aggregate stats (simple avg) for 4 processes 146 | Message succes rate: 100.00% (400/400 messages) 147 | Message timing mean 510.17 ms 148 | Message timing stddev 15.83 ms 149 | Message timing min 481.82 ms 150 | Message timing max 520.51 ms 151 | Messages per second 49.78 152 | Total time 3.35 secs 153 | ``` 154 | 155 | malaria subscribe 156 | ================== 157 | The subscriber side can listen to a broker and print out stats as messages 158 | are received. It needs to be told how many messages and how many virtual 159 | clients it should expect, and at least at present, requires the publisher to 160 | be operating in "time of flight tracking" mode. (Instrumented payloads) 161 | 162 | This works in single threaded mode (at present) modelling the use case of a 163 | central data processor. It aborts if it ever detects messages being dropped. 164 | 165 | ``` 166 | usage: malaria subscribe [-h] [-c CLIENTID] [-H HOST] [-p PORT] 167 | [-q {0,1,2}] [-n MSG_COUNT] [-N CLIENT_COUNT] 168 | [-t TOPIC] 169 | 170 | Listen to a stream of messages and capture statistics on their timing 171 | 172 | optional arguments: 173 | -h, --help show this help message and exit 174 | -c CLIENTID, --clientid CLIENTID 175 | Set the client id of the listner, can be useful for 176 | acls Default has pid information appended. (default: 177 | beem.listr-8391) 178 | -H HOST, --host HOST MQTT host to connect to (default: localhost) 179 | -p PORT, --port PORT Port for remote MQTT host (default: 1883) 180 | -q {0,1,2}, --qos {0,1,2} 181 | set the mqtt qos for subscription (default: 1) 182 | -n MSG_COUNT, --msg_count MSG_COUNT 183 | How many messages to expect (default: 10) 184 | -N CLIENT_COUNT, --client_count CLIENT_COUNT 185 | How many clients to expect. See docs for examples of 186 | how this works (default: 1) 187 | -t TOPIC, --topic TOPIC 188 | Topic to subscribe to, will be sorted into clients by 189 | the '+' symbol (default: mqtt-malaria/+/data/#) 190 | ``` 191 | 192 | Examples 193 | -------- 194 | To monitor a publisher of 500 processes, 1000 msgs per process: 195 | ``` 196 | malaria subscribe -n 1000 -N 500 197 | ``` 198 | 199 | Example output: 200 | ``` 201 | $ ./malaria subscribe -n 1000 -N 500 202 | INFO:beem.listen:beem.listr-8518:Listening for 500000 messages on topic mqtt-malaria/+/data/# (q1) 203 | DEBUG:beem.listen:beem.listr-8518:Storing initial drop count: 62491 204 | INFO:beem.listen:beem.listr-8518:Still waiting for 500000 messages 205 | INFO:beem.listen:beem.listr-8518:Still waiting for 500000 messages 206 | ----snip----- 207 | INFO:beem.listen:beem.listr-8518:Still waiting for 16997 messages 208 | INFO:beem.listen:beem.listr-8518:Still waiting for 14550 messages 209 | INFO:beem.listen:beem.listr-8518:Still waiting for 12031 messages 210 | INFO:beem.listen:beem.listr-8518:Still waiting for 9608 messages 211 | INFO:beem.listen:beem.listr-8518:Still waiting for 7130 messages 212 | INFO:beem.listen:beem.listr-8518:Still waiting for 4626 messages 213 | INFO:beem.listen:beem.listr-8518:Still waiting for 2247 messages 214 | INFO:beem.listen:beem.listr-8518:Still waiting for 744 messages 215 | INFO:beem.listen:beem.listr-8518:Still waiting for 41 messages 216 | INFO:beem.listen:beem.listr-8518:Still waiting for 0 messages 217 | Clientid: beem.listr-8518 218 | Total clients tracked: 500 219 | Total messages: 500000 220 | Total time: 203.94 secs 221 | Messages per second: 2451 (0.407888 ms per message) 222 | Messages duplicated: [] 223 | Flight time mean: 991.66 ms 224 | Flight time stddev: 414.76 ms 225 | Flight time min: 2.29 ms 226 | Flight time max: 2076.39 ms 227 | 228 | ``` 229 | 230 | 231 | Similar Work 232 | ============ 233 | Bees with Machine guns was the original inspiration, and I still intend to 234 | set this up such that the publisher components can be run as "bees" 235 | 236 | Also 237 | * http://affolter-engineering.ch/mqtt-randompub/ Appeared as I was finishing this. 238 | * https://github.com/chirino/mqtt-benchmark 239 | * http://www.ekito.fr/people/mqtt-benchmarks-rabbitmq-activemq/ 240 | * https://github.com/bluewindthings/mqtt-stress 241 | * https://github.com/tuanhiep/mqtt-jmeter 242 | 243 | -------------------------------------------------------------------------------- /fabfile.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Fabric malaria_split_keys for running malaria on multiple hosts 3 | # Karl Palsson, September, 2013, 4 | 5 | import json 6 | import os 7 | import random 8 | import tempfile 9 | import time 10 | 11 | import fabric.api as fab 12 | from fabtools.vagrant import vagrant # for CLI usage 13 | import fabtools as fabt 14 | import boto 15 | import boto.ec2 16 | 17 | import beem.cmds.keygen as keygen 18 | 19 | fab.env.project = "malaria" 20 | 21 | 22 | STATE_FILE = os.path.expanduser("~/.malaria") 23 | 24 | 25 | def _load_state(): 26 | if not os.path.isfile(STATE_FILE): 27 | return None 28 | 29 | return json.load(open(STATE_FILE, 'r')) 30 | 31 | 32 | def _save_state(state): 33 | json.dump(state, open(STATE_FILE, 'w'), 34 | sort_keys=True, indent=4) 35 | 36 | 37 | @fab.runs_once 38 | def _pack(): 39 | fab.local("rm -rf dist") 40 | # FIXME - this bit can't be parallel actually.... 41 | # come up with some other way to check for this... 42 | fab.local("python setup.py sdist") 43 | # figure out the release name and version 44 | dist = fab.local('python setup.py --fullname', capture=True).strip() 45 | return dist 46 | 47 | 48 | @fab.task 49 | def deploy(install_mosquitto=False): 50 | """ 51 | Install malaria on a given host. 52 | 53 | malaria will be installed to a virtual env in a temporary directory, and 54 | /tmp/malaria-tmp-homedir will contain the full path for subsequent 55 | operations on this host. 56 | 57 | if deploy:True, then install mosquitto and mosquitto-clients as well, 58 | this is necessary if you are hoping to use bridge tests, but is not done 59 | automatically as you may wish to use a specific version of mosquitto 60 | """ 61 | everybody(install_mosquitto) 62 | 63 | # This has to be serial, as it runs locally 64 | dist = _pack() 65 | 66 | # Make a very temporary "home" remotely 67 | fab.env.malaria_home = fab.run("mktemp -d -t malaria-tmp-XXXXXX") 68 | 69 | # upload the source tarball to the temporary folder on the server 70 | fab.put('dist/%s.tar.gz' % dist, 71 | '%(malaria_home)s/%(project)s.tar.gz' % fab.env) 72 | 73 | # Now make sure there's a venv and install ourselves into it. 74 | venvpath = "%(malaria_home)s/venv" % fab.env 75 | fabt.require.python.virtualenv(venvpath) 76 | with fabt.python.virtualenv(venvpath): 77 | # work around https://github.com/ronnix/fabtools/issues/157 by upgrading pip 78 | # and also work around require.python.pip using sudo! 79 | with fab.settings(sudo_user=fab.env.user): 80 | fabt.require.python.pip() 81 | fabt.python.install("%(malaria_home)s/%(project)s.tar.gz" % fab.env) 82 | fabt.require.files.file("/tmp/malaria-tmp-homedir", contents=fab.env.malaria_home) 83 | 84 | 85 | @fab.task 86 | @fab.parallel 87 | def cleanup(): 88 | """ 89 | Remove all malaria code that was installed on a target 90 | 91 | TODO - should this attempt to stop running processes if any? 92 | """ 93 | fab.run("rm -rf /tmp/malaria-tmp-*") 94 | 95 | 96 | @fab.task 97 | def beeup(count, region="eu-west-1", ami="ami-c27b6fb6", group="quick-start-1", key_name="karl-malaria-bees-2013-oct-15"): 98 | """ 99 | Fire up X ec2 instances, 100 | no checking against how many you already have! 101 | Adds these to the .malaria state file, and saves their instance ids 102 | so we can kill them later. 103 | 104 | args: 105 | count (required) number of bees to spin up 106 | region (optional) defaults to "eu-west-1" 107 | ami (optional) defaults to ami-c27b6fb6 (Ubu 1204lts 32bit in eu-west1) 108 | ami-a53264cc is the same us-east-1 109 | group (optional) defaults to "quick-start-1" 110 | needs to be a security group that allows ssh in! 111 | key_name (optional) defaults to karl-malaria-bees-2013-oct-15 112 | you need to have precreated this in your AWS console and have the private key available 113 | """ 114 | count = int(count) 115 | state = _load_state() 116 | if state: 117 | fab.puts("already have hosts available: %s, will add %d more!" % 118 | (state["hosts"], count)) 119 | else: 120 | state = {"hosts": [], "aws_iids": []} 121 | 122 | ec2_connection = boto.ec2.connect_to_region(region) 123 | instance_type = "t1.micro" 124 | 125 | zones = ec2_connection.get_all_zones() 126 | zone = random.choice(zones).name 127 | 128 | reservation = ec2_connection.run_instances( 129 | image_id=ami, 130 | min_count=count, 131 | max_count=count, 132 | key_name=key_name, 133 | security_groups=[group], 134 | instance_type=instance_type, 135 | placement=zone 136 | ) 137 | print("Reservation is ", reservation) 138 | fab.puts("Waiting for Amazon to breed bees") 139 | instances = [] 140 | for i in reservation.instances: 141 | i.update() 142 | while i.state != "running": 143 | print(".") 144 | time.sleep(2) 145 | i.update() 146 | instances.append(i) 147 | fab.puts("Bee %s ready for action!" % i.id) 148 | # Tag these so that humans can understand these better in AWS console 149 | # (We just use a state file to spinup/spindown) 150 | ec2_connection.create_tags([i.id for i in instances], {"malaria": "bee"}) 151 | #state["aws_zone"] = no-one cares 152 | state["aws_iids"].extend([i.id for i in instances]) 153 | state["region"] = region 154 | for i in instances: 155 | hoststring = "%s@%s" % ("ubuntu", i.public_dns_name) 156 | state["hosts"].append(hoststring) 157 | fab.puts("Adding %s to our list of workers" % hoststring) 158 | _save_state(state) 159 | 160 | 161 | def beedown(iids, region="eu-west-1"): 162 | """Turn off all our bees""" 163 | ec2_connection = boto.ec2.connect_to_region(region) 164 | tids = ec2_connection.terminate_instances(instance_ids=iids) 165 | fab.puts("terminated ids: %s" % tids) 166 | 167 | 168 | @fab.task 169 | @fab.parallel 170 | def aptup(): 171 | fab.sudo("apt-get update") 172 | 173 | 174 | @fab.task 175 | @fab.parallel 176 | def everybody(install_mosquitto=False): 177 | # this is needed at least once, but should have been covered 178 | # by either vagrant bootstrap, or your cloud machine bootstrap 179 | # TODO - move vagrant bootstrap to a fab bootstrap target instead? 180 | #fab.sudo("apt-get update") 181 | family = fabt.system.distrib_family() 182 | if family == "debian": 183 | fabt.require.deb.packages([ 184 | "python-dev", 185 | "python-virtualenv" 186 | ]) 187 | if family == "redhat": 188 | fabt.require.rpm.packages([ 189 | "python-devel", 190 | "python-virtualenv" 191 | ]) 192 | 193 | if install_mosquitto: 194 | # FIXME - this only works for ubuntu.... 195 | fab.puts("Installing mosquitto from ppa") 196 | fabt.require.deb.packages(["python-software-properties"]) 197 | fabt.require.deb.ppa("ppa:mosquitto-dev/mosquitto-ppa") 198 | fabt.require.deb.packages(["mosquitto", "mosquitto-clients"]) 199 | 200 | 201 | @fab.task 202 | def up(): 203 | """ 204 | Prepare a set of hosts to be malaria attack nodes. 205 | 206 | The set of machines are saved in ~/.malaria for to help with repeated 207 | attacks and for cleanup. 208 | """ 209 | state = {"hosts": fab.env.hosts} 210 | deploy() 211 | _save_state(state) 212 | 213 | 214 | @fab.task 215 | def mstate(): 216 | """ 217 | Set fabric hosts from ~/.malaria 218 | 219 | Use this to help you run tasks on machines already configured. 220 | $ fab beeup:3 221 | $ fab mstate deploy 222 | or 223 | $ fab vagrant up XXXX Tidy up and make this more consistent!!! 224 | $ fab mstate attack:target.machine 225 | 226 | """ 227 | state = _load_state() 228 | fab.env.hosts = state["hosts"] 229 | 230 | 231 | @fab.task 232 | @fab.parallel 233 | def attack(target, warhead=None): 234 | """ 235 | Launch an attack against "target" with all nodes from "up" 236 | 237 | "warhead" is a file of attack commands that will be run inside the 238 | malaria virtual environment on the attacking node. See examples in 239 | the warheads directory. 240 | """ 241 | fab.env.malaria_home = fab.run("cat /tmp/malaria-tmp-homedir") 242 | fab.env.malaria_target = target 243 | cmd = [] 244 | 245 | if warhead: 246 | with open(warhead, "r") as f: 247 | cmds = [x for x in f if x.strip() and x[0] not in "#;"] 248 | else: 249 | cmds = ["malaria publish -n 10 -P 10 -t -T 1 -H %(malaria_target)s"] 250 | 251 | with fabt.python.virtualenv("%(malaria_home)s/venv" % fab.env): 252 | for cmd in cmds: 253 | fab.run(cmd % fab.env) 254 | 255 | 256 | @fab.task 257 | @fab.parallel 258 | def abort(): 259 | """ 260 | Attempt to kill all processes that might be related to a malaria attack 261 | 262 | """ 263 | with fab.settings(warn_only=True): 264 | fab.run("kill $(pgrep malaria)", ) 265 | # TODO - should only be done when bridging? 266 | fab.run("kill $(pgrep mosquitto)") 267 | 268 | @fab.task 269 | def down(): 270 | """ 271 | Clear our memory and cleanup all malaria code on all attack nodes 272 | """ 273 | state = _load_state() 274 | if not state: 275 | fab.abort("No state file found with active servers? %s" % STATE_FILE) 276 | if "aws_iids" in state: 277 | beedown(state["aws_iids"], region=state["region"]) 278 | else: 279 | fab.puts("Cleaning up regular hosts (that we leave running)") 280 | fab.env.hosts = state["hosts"] 281 | fab.execute(cleanup) 282 | fab.local("rm -f ~/.malaria") 283 | 284 | 285 | @fab.task 286 | def observe(): 287 | """ 288 | Watch the outcome of the attack 289 | 290 | Run this to setup the stats collector on the target 291 | """ 292 | fab.env.malaria_home = fab.run("cat /tmp/malaria-tmp-homedir") 293 | 294 | with fabt.python.virtualenv("%(malaria_home)s/venv" % fab.env): 295 | # Default is for the two vagrant machines, default attack command 296 | while True: 297 | cmd = fab.prompt( 298 | "Enter command to run in malaria virtual env $", 299 | default="malaria subscribe -n 10 -N 20") 300 | if cmd.strip(): 301 | fab.run(cmd % fab.env) 302 | else: 303 | fab.puts("Ok, done done!") 304 | break 305 | 306 | 307 | @fab.task 308 | @fab.parallel 309 | def publish(target, *args): 310 | deploy() 311 | with fabt.python.virtualenv("%(malaria_home)s/venv" % fab.env): 312 | #fab.run("malaria publish -n 10 -P 10 -t -T 1 -H %s" % target) 313 | fab.run("malaria publish -H %s %s" % (target, ' '.join(args))) 314 | cleanup() 315 | 316 | 317 | @fab.task 318 | @fab.serial 319 | def listen(target, *args): 320 | deploy() 321 | with fabt.python.virtualenv("%(malaria_home)s/venv" % fab.env): 322 | #fab.run("malaria subscribe -n 10 -N 20 -H %s" % target) 323 | fab.run("malaria subscribe -H %s %s" % (target, ' '.join(args))) 324 | cleanup() 325 | 326 | 327 | @fab.task 328 | @fab.runs_once 329 | def _presplit(keyfile): 330 | """magically split the file into ram and tack it into the fab.env....""" 331 | with open(keyfile, "r") as f: 332 | inputs = f.readlines() 333 | count = len(fab.env.hosts) 334 | fab.env.malaria_split_keys = dict(zip(fab.env.hosts, 335 | keygen.chunks(inputs, count))) 336 | 337 | 338 | @fab.task 339 | @fab.parallel 340 | def share_key(keyfile, fname="/tmp/malaria-tmp-keyfile"): 341 | """ 342 | Take a key file and split it amongst all the given hosts, 343 | installs to /tmp/malaria-tmp-keyfile. 344 | TODO: should save it into the %(malaria_home) directory? 345 | """ 346 | fab.execute(_presplit, keyfile) 347 | fab.puts("Distributing keys to host: %s" % fab.env.host_string) 348 | our_keys = fab.env.malaria_split_keys[fab.env.host_string] 349 | with tempfile.NamedTemporaryFile() as f: 350 | [f.write(l) for l in our_keys] 351 | f.flush() 352 | fab.put(f.name, fname) 353 | -------------------------------------------------------------------------------- /beem/listen.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013, ReMake Electric ehf 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # 7 | # 1. Redistributions of source code must retain the above copyright notice, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright 10 | # notice, this list of conditions and the following disclaimer in the 11 | # documentation and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 15 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 16 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 17 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 18 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 19 | # SUBSTITUTE GOODS OR SERVICES LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 20 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 21 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 22 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 23 | # POSSIBILITY OF SUCH DAMAGE. 24 | """ 25 | This is a module for a single message consumption process 26 | It listens to a topic and expects to see a known sequence of messages. 27 | """ 28 | 29 | from __future__ import division 30 | 31 | import collections 32 | import errno 33 | import logging 34 | import math 35 | import os 36 | import stat 37 | import tempfile 38 | import time 39 | 40 | import fuse 41 | import paho.mqtt.client as mqtt 42 | 43 | from beem.trackers import ObservedMessage as MsgStatus 44 | 45 | 46 | class TrackingListener(): 47 | """ 48 | An MQTT message subscriber that tracks an expected message sequence 49 | and generates timing, duplicate/missing and monitors for drops 50 | """ 51 | 52 | msg_statuses = [] 53 | 54 | def __init__(self, host, port, opts): 55 | self.options = opts 56 | self.cid = opts.clientid 57 | self.log = logging.getLogger(__name__ + ":" + self.cid) 58 | self.mqttc = mqtt.Client(self.cid) 59 | self.mqttc.on_message = self.msg_handler 60 | self.listen_topic = opts.topic 61 | self.time_start = None 62 | # TODO - you _probably_ want to tweak this 63 | self.mqttc.max_inflight_messages_set(200) 64 | rc = self.mqttc.connect(host, port, 60) 65 | if rc: 66 | raise Exception("Couldn't even connect! ouch! rc=%d" % rc) 67 | # umm, how? 68 | self.mqttc.subscribe('$SYS/broker/publish/messages/dropped', 0) 69 | self.drop_count = None 70 | self.dropping = False 71 | self.mqttc.loop_start() 72 | 73 | def msg_handler(self, mosq, userdata, msg): 74 | # WARNING: this _must_ release as quickly as possible! 75 | # get the sequence id from the topic 76 | #self.log.debug("heard a message on topic: %s", msg.topic) 77 | if msg.topic == '$SYS/broker/publish/messages/dropped': 78 | if self.drop_count: 79 | self.log.warn("Drop count has increased by %d", 80 | int(msg.payload) - self.drop_count) 81 | self.dropping = True 82 | else: 83 | self.drop_count = int(msg.payload) 84 | self.log.debug("Initial drop count: %d", self.drop_count) 85 | return 86 | if not self.time_start: 87 | self.time_start = time.time() 88 | 89 | try: 90 | ms = MsgStatus(msg) 91 | self.msg_statuses.append(ms) 92 | except Exception: 93 | self.log.exception("Failed to parse a received message. (Is the publisher sending time-tracking information with -t?)") 94 | 95 | def run(self, qos=1): 96 | """ 97 | Start a (long lived) process waiting for messages to arrive. 98 | The number of clients and messages per client that are expected 99 | are set at creation time 100 | 101 | """ 102 | self.expected = self.options.msg_count * self.options.client_count 103 | self.log.info( 104 | "Listening for %d messages on topic %s (q%d)", 105 | self.expected, self.listen_topic, qos) 106 | rc = self.mqttc.subscribe(self.listen_topic, qos) 107 | #assert rc == 0, "Failed to subscribe?! this isn't handled!", rc 108 | while len(self.msg_statuses) < self.expected: 109 | # let the mosquitto thread fill us up 110 | time.sleep(1) 111 | self.log.info("Still waiting for %d messages", 112 | self.expected - len(self.msg_statuses)) 113 | if self.dropping: 114 | self.log.error("Detected drops are occuring, aborting test!") 115 | break 116 | self.time_end = time.time() 117 | self.mqttc.disconnect() 118 | 119 | def stats(self): 120 | msg_count = len(self.msg_statuses) 121 | flight_times = [x.time_flight() for x in self.msg_statuses] 122 | mean = sum(flight_times) / len(flight_times) 123 | squares = [x * x for x in [q - mean for q in flight_times]] 124 | stddev = math.sqrt(sum(squares) / len(flight_times)) 125 | 126 | actual_clients = set([x.cid for x in self.msg_statuses]) 127 | per_client_expected = range(1, self.options.msg_count + 1) 128 | per_client_real = {} 129 | per_client_missing = {} 130 | for cid in actual_clients: 131 | per_client_real[cid] = [x.mid for x in self.msg_statuses if x.cid == cid] 132 | per_client_missing[cid] = list(set(per_client_expected).difference(set(per_client_real[cid]))) 133 | 134 | return { 135 | "clientid": self.cid, 136 | "client_count": len(actual_clients), 137 | "test_complete": not self.dropping, 138 | "msg_duplicates": [x for x, y in collections.Counter(self.msg_statuses).items() if y > 1], 139 | "msg_missing": per_client_missing, 140 | "msg_count": msg_count, 141 | "ms_per_msg": (self.time_end - self.time_start) / msg_count * 1000, 142 | "msg_per_sec": msg_count / (self.time_end - self.time_start), 143 | "time_total": self.time_end - self.time_start, 144 | "flight_time_mean": mean, 145 | "flight_time_stddev": stddev, 146 | "flight_time_max": max(flight_times), 147 | "flight_time_min": min(flight_times) 148 | } 149 | 150 | 151 | def static_file_attrs(content=None): 152 | now = time.time() 153 | if content: 154 | size = len(content) 155 | else: 156 | size = 20 157 | return { 158 | "file": dict(st_mode=(stat.S_IFREG | 0444), st_nlink=1, 159 | st_size=size, 160 | st_ctime=now, st_mtime=now, 161 | st_atime=now), 162 | "content": content 163 | } 164 | 165 | 166 | class MalariaWatcherStatsFS(fuse.LoggingMixIn, fuse.Operations): 167 | 168 | file_attrs = dict(st_mode=(stat.S_IFREG | 0444), st_nlink=1, 169 | st_size=20000, 170 | st_ctime=time.time(), st_mtime=time.time(), 171 | st_atime=time.time()) 172 | 173 | dir_attrs = dict(st_mode=(stat.S_IFDIR | 0755), st_nlink=2, 174 | st_ctime=time.time(), st_mtime=time.time(), 175 | st_atime=time.time()) 176 | README_STATFS = """ 177 | This is a FUSE filesystem that contains a set of files representing various 178 | statistics we have gathered about the MQTT broker we are watching and the 179 | topics we are subscribed to. 180 | """ 181 | msgs_total = 0 182 | msgs_stored = 0 183 | drop_count = 0 184 | drop_count_initial = None 185 | 186 | def handle_msgs_total(self): 187 | """Total number of messages seen since we started""" 188 | return self.msgs_total 189 | 190 | def handle_msgs_stored(self): 191 | """Total number of stored ($sys/broker/messages/stored)""" 192 | return self.msgs_stored 193 | 194 | def handle_uptime(self): 195 | """Time in seconds this watcher has been running""" 196 | return time.time() - self.time_start 197 | 198 | def handle_drop_count(self): 199 | """Total drops since this watcher has been running""" 200 | return self.drop_count 201 | 202 | def handle_topic(self): 203 | """The topics this watcher is subscribing too""" 204 | return '\n'.join(self.listen_topics) 205 | 206 | def handle_readme(self): 207 | """Returns 'this' readme ;)""" 208 | rval = self.README_STATFS 209 | useful = [x for x in self.handlers if x != '/'] 210 | file_field = "File " 211 | rval += "\n" + file_field + "Description\n\n" 212 | for h in useful: 213 | func = self.handlers[h].get("handler", None) 214 | desc = None 215 | if not func: 216 | desc = "Raw file, no further description" 217 | if func: 218 | desc = func.__doc__ 219 | if not desc: 220 | desc = "No description in handler method! (Fix pydoc!)" 221 | # pad file line to line up with the description 222 | line = "%s%s\n" % (str.ljust(h[1:], len(file_field)), desc) 223 | rval += line 224 | return rval 225 | 226 | handlers = { 227 | "/": {"file": dir_attrs, "handler": None}, 228 | "/msgs_total": {"file": file_attrs, "handler": handle_msgs_total}, 229 | "/msgs_stored": {"file": file_attrs, "handler": handle_msgs_stored}, 230 | "/uptime": {"file": file_attrs, "handler": handle_uptime}, 231 | "/topic": {"file": file_attrs, "handler": handle_topic}, 232 | "/drop_count": {"file": file_attrs, "handler": handle_drop_count}, 233 | "/README": static_file_attrs(README_STATFS), 234 | "/README.detailed": {"file": file_attrs, "handler": handle_readme} 235 | } 236 | 237 | def __init__(self, options): 238 | print("listener operations __init__") 239 | self.options = options 240 | self.time_start = time.time() 241 | 242 | def msg_handler(self, mosq, userdata, msg): 243 | # WARNING: this _must_ release as quickly as possible! 244 | # get the sequence id from the topic 245 | #self.log.debug("heard a message on topic: %s", msg.topic) 246 | if "/messages/dropped" in msg.topic: 247 | if self.drop_count_initial: 248 | self.log.warn("Drop count has increased by %d", 249 | (int(msg.payload) - self.drop_count_initial)) 250 | self.drop_count = int(msg.payload) - self.drop_count_initial 251 | else: 252 | self.drop_count_initial = int(msg.payload) 253 | self.log.debug("Initial drops: %d", self.drop_count_initial) 254 | return 255 | if "messages/stored" in msg.topic: 256 | self.msgs_stored = int(msg.payload) 257 | return 258 | self.msgs_total += 1 259 | 260 | def init(self, path): 261 | """ 262 | Fuse calls this when it's ready, so we can start our actual mqtt 263 | processes here. 264 | """ 265 | print("listener post init init(), path=", path) 266 | self.cid = self.options.clientid 267 | self.log = logging.getLogger(__name__ + ":" + self.cid) 268 | self.mqttc = mqtt.Client(self.cid) 269 | self.mqttc.on_message = self.msg_handler 270 | self.listen_topics = self.options.topic 271 | # TODO - you _probably_ want to tweak this 272 | self.mqttc.max_inflight_messages_set(200) 273 | rc = self.mqttc.connect(self.options.host, self.options.port, 60) 274 | if rc: 275 | raise Exception("Couldn't even connect! ouch! rc=%d" % rc) 276 | # umm, how? 277 | # b/p/m for >= 1.2, b/m for 1.1.x 278 | self.mqttc.subscribe('$SYS/broker/publish/messages/dropped', 0) 279 | self.mqttc.subscribe('$SYS/broker/messages/dropped', 0) 280 | self.mqttc.subscribe('$SYS/broker/messages/stored', 0) 281 | self.mqttc.loop_start() 282 | [self.mqttc.subscribe(t, self.options.qos) for t in self.listen_topics] 283 | 284 | def getattr(self, path, fh=None): 285 | if path not in self.handlers: 286 | raise fuse.FuseOSError(errno.ENOENT) 287 | 288 | return self.handlers[path]["file"] 289 | 290 | def read(self, path, size, offset, fh): 291 | if self.handlers[path].get("content", False): 292 | return self.handlers[path]["content"] 293 | funcy = self.handlers[path]["handler"] 294 | return str(funcy(self)) + "\n" 295 | 296 | def readdir(self, path, fh): 297 | return ['.', '..'] + [x[1:] for x in self.handlers if x != '/'] 298 | 299 | 300 | class CensusListener(): 301 | """ 302 | Create a listener that just watches all the messages go past. 303 | It doesn't care about time in flight or expected vs actual, it just cares 304 | about what it has seen, and maintains long term stats on whatever 305 | it does see. 306 | """ 307 | def __init__(self, options): 308 | self.log = logging.getLogger(__name__) 309 | path_provided = True 310 | if not options.directory: 311 | path_provided = False 312 | options.directory = tempfile.mkdtemp() 313 | self.log.info("Statistics files will be available in %s", options.directory) 314 | fuse.FUSE(MalariaWatcherStatsFS(options), 315 | options.directory, foreground=True) 316 | if not path_provided: 317 | self.log.info("Automatically removing statsfs: %s", options.directory) 318 | os.rmdir(options.directory) 319 | --------------------------------------------------------------------------------