├── requirements.txt ├── .gitignore ├── mqtt-launcher.service ├── launcher.conf.example ├── README.md └── mqtt-launcher.py /requirements.txt: -------------------------------------------------------------------------------- 1 | paho-mqtt 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | launcher.conf 3 | logfile 4 | env.v3 5 | bin/ 6 | include/ 7 | lib64 8 | lib/ 9 | pyvenv.cfg 10 | *.crt 11 | *.log -------------------------------------------------------------------------------- /mqtt-launcher.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=mqtt-launcher 3 | After=network.target 4 | 5 | [Service] 6 | PermissionsStartOnly=true 7 | ExecStart=/bin/bash -c 'source /opt/mqtt-launcher/bin/activate; /opt/mqtt-launcher/mqtt-launcher.py' 8 | WorkingDirectory=/opt/mqtt-launcher 9 | StandardOutput=inherit 10 | StandardError=inherit 11 | Restart=always 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | -------------------------------------------------------------------------------- /launcher.conf.example: -------------------------------------------------------------------------------- 1 | 2 | logfile = 'logfile' 3 | mqtt_broker = 'localhost' # default: 'localhost'. If using TLS, this must be set to the domain name signed by your TLS certificate. 4 | mqtt_port = 1883 # default: 1883 5 | mqtt_clientid = 'mqtt-launcher-1' 6 | mqtt_username = None 7 | mqtt_password = None 8 | mqtt_tls = None # default: No TLS 9 | mqtt_tls_verify = None # Configure verification of the server hostname in the server certificate, None means to not vorifying Hostname and should not be used in production 10 | mqtt_transport_type = 'tcp' # alternative: 'websocket', default: 'tcp' 11 | 12 | topiclist = { 13 | 14 | # topic payload value program & arguments 15 | "sys/file" : { 16 | 'create' : [ '/usr/bin/touch', '/tmp/file.one' ], 17 | 'false' : [ '/bin/rm', '-f', '/tmp/file.one' ], 18 | 'info' : [ '/bin/ls', '-l', '/tmp/file.one' ], 19 | }, 20 | "prog/pwd" : { 21 | None : [ 'pwd' ], 22 | }, 23 | "dev/1" : { 24 | None : [ 'ls', '-l', '/' ], 25 | }, 26 | "dev/2" : { 27 | None : [ "/bin/echo", "111", "*", "@!@", "222", "@!@", "333" ], 28 | }, 29 | "dev/3" : { 30 | None : [ "/bin/sh", '-c', 'var=@!@; echo $var'], 31 | }, 32 | "dev/4" : { 33 | None : [ "/bin/bash", 34 | '-c', 35 | 'IFS="/" read -r var1 var2 <<< "@!@"; echo "var1=$var1 var2=$var2"'], 36 | }, 37 | } 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mqtt-launcher 2 | 3 | _mqtt-launcher_ is a Python program which subscribes to a set of [MQTT] topics 4 | and executes processes on the host it's running on. Launchable processes are 5 | configured on a per/wildcard basis, and they can be constrained to run only if 6 | a particular text payload is contained in the message. 7 | 8 | For example, I can publish a message to my MQTT broker requesting _mqtt-launcher_ 9 | create a particular semaphore file for me: 10 | 11 | ``` 12 | mosquitto_pub -t sys/file -m create 13 | ``` 14 | 15 | The configuration file(s) must be valid Python. Configuration is loaded once and contains 16 | the topic / process associations. 17 | 18 | ```python 19 | # topic payload value program & arguments 20 | "sys/file" : { 21 | 'create' : [ '/usr/bin/touch', '/tmp/file.one' ], 22 | 'false' : [ '/bin/rm', '-f', '/tmp/file.one' ], 23 | 'info' : [ '/bin/ls', '-l', '/tmp/file.one' ], 24 | }, 25 | ``` 26 | 27 | Above snippet instructs _mqtt-launcher_ to: 28 | 29 | * subscribe to the [MQTT] topic `sys/file` 30 | * look up the payload string and launch the associated programs: 31 | * if the payload is `create`, then _touch_ a file 32 | * if the payload is the string `false`, remove a file 33 | * if the payload is `info`, return information on the file 34 | 35 | The payload value may be `None` in which case the eacho of the list elements 36 | defining the program and arguments are checked for the magic string `@!@` which 37 | is replaced by the payload contents. (See example published at `dev/2`, `dev/3` and `dev/4` below.) 38 | 39 | _mqtt-launcher_ publishes _stdout_ and _stderr_ of the launched program 40 | to the configured topic with `/report` added to it. So, in the example 41 | above, a non-retained message will be published to `sys/file/report`. 42 | (Note that this message contains whatever the command outputs; trailing 43 | white space is truncated.) 44 | 45 | ## Screenshot 46 | 47 | Here's the obligatory "screenshot". 48 | 49 | ``` 50 | Publishes Subscribes 51 | ----------------------- ------------------------------------------------------------------ 52 | $ mosquitto_sub -v -t 'dev/#' -t 'sys/file/#' -t 'prog/#' 53 | 54 | 55 | mosquitto_pub -t prog/pwd -n 56 | prog/pwd (null) 57 | prog/pwd/report /private/tmp 58 | 59 | mosquitto_pub -t sys/file -m create 60 | sys/file create 61 | sys/file/report (null) # command has no output 62 | 63 | mosquitto_pub -t sys/file -m info 64 | sys/file info 65 | sys/file/report -rw-r--r-- 1 jpm wheel 0 Jan 22 16:10 /tmp/file.one 66 | 67 | mosquitto_pub -t sys/file -m remove 68 | sys/file remove 69 | # report not published: subcommand ('remove') doesn't exist 70 | # log file says: 71 | 2014-01-22 16:11:30,393 No matching param (remove) for sys/file 72 | 73 | mosquitto_pub -t dev/1 -m hi 74 | dev/1 hi 75 | dev/1/report total 16231 76 | drwxrwxr-x+ 157 root admin 5338 Jan 20 10:48 Applications 77 | drwxrwxr-x@ 8 root admin 272 Jan 25 2013 Developer 78 | drwxr-xr-x+ 72 root wheel 2448 Oct 14 10:54 Library 79 | ... 80 | mosquitto_pub -t dev/2 -m 'Hi Jane!' 81 | dev/2 Hi Jane! 82 | dev/2/report 111 * Hi Jane! 222 Hi Jane! 333 83 | 84 | mosquitto_pub -t dev/3 -m 'foo-bar' 85 | dev/3 foo-bar 86 | dev/3/report foo-bar 87 | 88 | mosquitto_pub -t dev/4 -m 'foo/bar' 89 | dev/4 foo/bar 90 | dev/4/report var1=foo var2=bar 91 | ``` 92 | 93 | ## Configuration 94 | 95 | _mqtt-launcher_ loads a Python configuration from the path contained in 96 | the environment variable `$MQTTLAUNCHERCONFIG`; if unset, the path 97 | defaults to `launcher.conf`. See the provided `launcher.conf.example`. 98 | 99 | Additional configuration files may be placed in a subdirectory of the 100 | main configuration file path. These are read in lexical order and merged 101 | into the final configuration, which allows for per-machine overrides and 102 | separate storage of settings like the broker password. The subdirectory 103 | must be named the same as the configuration file with a `.d` suffix. Example: 104 | ``` 105 | ├── launcher.conf 106 | └── launcher.conf.d 107 |    ├── 10-password 108 |    ├── 20-topics-common 109 |    └── 30-topics-local 110 | ``` 111 | 112 | If multiple files define their own `topiclist`, they are merged similarly. 113 | Topics from subsequent files override those with identical names in 114 | preceding ones. 115 | 116 | ## Logging 117 | 118 | _mqtt-launcher_ logs its operation in the file configured as `logfile`. 119 | 120 | ## Requirements 121 | 122 | * Python 123 | * [paho-mqtt](https://pypi.python.org/pypi/paho-mqtt/1.3.1) 124 | 125 | ## Credits 126 | 127 | This program was inspired by two related tools: 128 | * Peter van Dijk's [mqtt-spawn](https://github.com/PowerDNS/mqtt-spawn) 129 | * Dennis Schulte's [mqtt-exec](https://github.com/denschu/mqtt-exec). (I'm not terribly comfortable running NodeJS programs, so I implemented the idea in Python.) 130 | 131 | [MQTT]: http://mqtt.org 132 | -------------------------------------------------------------------------------- /mqtt-launcher.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # Copyright (c) 2014 Jan-Piet Mens 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 | # 3. Neither the name of mosquitto nor the names of its 15 | # contributors may be used to endorse or promote products derived from 16 | # this software without specific prior written permission. 17 | # 18 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 21 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 22 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 23 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 24 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 25 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 26 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 27 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 28 | # POSSIBILITY OF SUCH DAMAGE. 29 | 30 | __author__ = 'Jan-Piet Mens ' 31 | __copyright__ = 'Copyright 2014 Jan-Piet Mens' 32 | 33 | import os 34 | import sys 35 | from pathlib import Path 36 | import subprocess 37 | import logging 38 | import paho.mqtt.client as paho # pip install paho-mqtt 39 | import time 40 | import socket 41 | import string 42 | 43 | qos=2 44 | CONFIG=os.getenv('MQTTLAUNCHERCONFIG', 'launcher.conf') 45 | TOPIC_LIST_KEY='topiclist' 46 | 47 | class Config(object): 48 | 49 | def __init__(self, filename=CONFIG): 50 | self.config = {} 51 | conf_dir = Path(f"{filename}.d") 52 | conf_files = [filename] + ( 53 | sorted([f for f in conf_dir.iterdir() if f.is_file()]) if conf_dir.is_dir() else [] 54 | ) 55 | merged_topics = {} 56 | for conf_file in conf_files: 57 | exec(compile(open(conf_file, "rb").read(), conf_file, "exec"), self.config) 58 | merged_topics.update(self.config.get(TOPIC_LIST_KEY, {})) 59 | self.config[TOPIC_LIST_KEY] = merged_topics 60 | 61 | def get(self, key, default=None): 62 | return self.config.get(key, default) 63 | 64 | try: 65 | cf = Config() 66 | except Exception as e: 67 | print("Cannot load configuration from %s or %s.d subdirectory: %s" % (CONFIG, CONFIG, str(e))) 68 | sys.exit(2) 69 | 70 | LOGFILE = cf.get('logfile', 'logfile') 71 | LOGFORMAT = '%(asctime)-15s %(message)s' 72 | DEBUG=True 73 | 74 | if DEBUG: 75 | logging.basicConfig(filename=LOGFILE, level=logging.DEBUG, format=LOGFORMAT) 76 | else: 77 | logging.basicConfig(filename=LOGFILE, level=logging.INFO, format=LOGFORMAT) 78 | 79 | logging.info("Starting") 80 | logging.debug("DEBUG MODE") 81 | 82 | def runprog(topic, param=None): 83 | 84 | publish = "%s/report" % topic 85 | 86 | if param is not None and all(c in string.printable for c in param) == False: 87 | logging.debug("Param for topic %s is not printable; skipping" % (topic)) 88 | return 89 | 90 | if not topic in topiclist: 91 | logging.info("Topic %s isn't configured" % topic) 92 | return 93 | 94 | if param is not None and param in topiclist[topic]: 95 | cmd = topiclist[topic].get(param) 96 | else: 97 | if None in topiclist[topic]: ### and topiclist[topic][None] is not None: 98 | cmd = [p.replace('@!@', param) for p in topiclist[topic][None]] 99 | else: 100 | logging.info("No matching param (%s) for %s" % (param, topic)) 101 | return 102 | 103 | logging.debug("Running t=%s: %s" % (topic, cmd)) 104 | 105 | try: 106 | res = subprocess.check_output(cmd, stdin=None, stderr=subprocess.STDOUT, shell=False, universal_newlines=True, cwd='/tmp') 107 | except Exception as e: 108 | res = "*****> %s" % str(e) 109 | 110 | payload = res.rstrip('\n') 111 | (res, mid) = mqttc.publish(publish, payload, qos=qos, retain=False) 112 | 113 | def on_connect(client, userdata, flags, reason_code, properties): 114 | if reason_code == 0: 115 | logging.debug("Connected to MQTT broker, subscribing to topics...") 116 | for topic in topiclist: 117 | mqttc.subscribe(topic, qos) 118 | logging.debug("Subscribed to Topic \"%s\", QOS %s", topic, qos) 119 | if reason_code > 0: 120 | logging.debug("Connected with result code: %s", reason_code) 121 | logging.debug("No connection. Aborting") 122 | sys.exit(2) 123 | 124 | def on_message(client, userdata, msg): 125 | logging.debug(msg.topic+" "+str(msg.qos)+" "+msg.payload.decode('utf-8')) 126 | 127 | runprog(msg.topic, msg.payload.decode('utf-8')) 128 | 129 | def on_disconnect(client, userdata, flags, reason_code, properties): 130 | logging.debug("OOOOPS! launcher disconnects") 131 | time.sleep(10) 132 | 133 | if __name__ == '__main__': 134 | 135 | userdata = { 136 | } 137 | topiclist = cf.get(TOPIC_LIST_KEY) 138 | 139 | if not topiclist: 140 | logging.info("No topic list. Aborting") 141 | sys.exit(2) 142 | 143 | clientid = cf.get('mqtt_clientid', 'mqtt-launcher-%s' % os.getpid()) 144 | 145 | transportType = cf.get('mqtt_transport_type', 'tcp') 146 | 147 | # initialise MQTT broker connection 148 | mqttc = paho.Client(paho.CallbackAPIVersion.VERSION2, clientid, clean_session=False, transport=transportType) 149 | 150 | mqttc.on_message = on_message 151 | mqttc.on_connect = on_connect 152 | mqttc.on_disconnect = on_disconnect 153 | 154 | mqttc.will_set('clients/mqtt-launcher', payload="Adios!", qos=0, retain=False) 155 | 156 | # Delays will be: 3, 6, 12, 24, 30, 30, ... 157 | #mqttc.reconnect_delay_set(delay=3, delay_max=30, exponential_backoff=True) 158 | 159 | if cf.get('mqtt_username') is not None: 160 | mqttc.username_pw_set(cf.get('mqtt_username'), cf.get('mqtt_password')) 161 | 162 | if cf.get('mqtt_tls') is not None: 163 | if cf.get('mqtt_tls_ca') is not None: 164 | mqttc.tls_set(ca_certs=cf.get('mqtt_tls_ca')) 165 | else: 166 | mqttc.tls_set() 167 | 168 | if cf.get('mqtt_tls_verify') is not None: 169 | mqttc.tls_insecure_set(False) 170 | 171 | if transportType == 'websockets': 172 | mqttc.ws_set_options(path="/ws") 173 | 174 | mqttc.connect(cf.get('mqtt_broker', 'localhost'), int(cf.get('mqtt_port', '1883')), 60) 175 | 176 | while True: 177 | try: 178 | mqttc.loop_forever(retry_first_connection=False) 179 | except socket.error: 180 | time.sleep(5) 181 | except KeyboardInterrupt: 182 | sys.exit(0) 183 | --------------------------------------------------------------------------------