├── 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 |
--------------------------------------------------------------------------------