├── Dockerfile ├── LICENSE ├── README.md ├── conf ├── example:echo:+:in └── example:timer:+:set ├── go.mod ├── go.sum ├── main.go ├── router └── router.go └── server ├── proto.go └── server.go /Dockerfile: -------------------------------------------------------------------------------- 1 | # build stage 2 | FROM golang:1.11-rc-stretch AS build-env 3 | WORKDIR /src 4 | COPY . /src 5 | RUN CGO_ENABLED=0 go build -o moquette 6 | 7 | # final stage 8 | FROM alpine 9 | RUN apk add --no-cache bash 10 | COPY --from=build-env /src/moquette /app/moquette 11 | ENTRYPOINT ["/app/moquette"] 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Olivier Poitrey 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Moquette — MQTT Service Dispatcher 2 | 3 | Moquette is to MQTT what inetd is to IP. Moquette listens for events from an MQTT broker and executes a process (event handler) found in its configuration directory if its name matches the event's topic. The matching obeys to the MQTT topic rules. Slashes in the topic are replaced by colon (:). 4 | 5 | For instance, the following file names will all match the topic `home/office/lamp/setOn`: 6 | 7 | * `home:office:lamp:setOn` 8 | * `home:office:+:setOn` 9 | * `home:office:#` 10 | * `#` 11 | 12 | Event handler files must be at the root of the Moquette configuration directory and have their executable flag set. The default directory is `/etc/moquette.d` and can be changed using the `--conf` option. New files can be added/removed while Moquette is running, without the need to restart it. 13 | 14 | When an event handler is executed, Moquette sends the event payload as first argument to the command and the event topic is set as the `$MQTT_TOPIC` environment variable. The message ID is also transmitted using the `$MQTT_MSGID` environment variable. 15 | 16 | A command can send events back by writing to the file descriptor number 3. The Format is as follow: 17 | 18 | PUB \n 19 | 20 | 21 | For instance, to send "hello world" on the `example/somewhere` topic using bash: 22 | 23 | ```bash 24 | msg="hello world" 25 | echo -e "PUB example/somewhere 0 ${#msg}\n$msg" >&3 26 | ``` 27 | 28 | Moquette will wait as long as necessary for the process to finish its execution. This way it is possible to delay the response to an event, or send multiple events spread in time to implement a timer for instance. 29 | 30 | ## Handler Examples 31 | 32 | The examples below are written in bash, but handlers can be written in any language. You can find more examples in the [conf](conf/) directory. 33 | 34 | ### example:echo:+:in 35 | 36 | ```bash 37 | #!/bin/bash 38 | 39 | echo -e "PUB ${MQTT_TOPIC%*in}out 0 ${#1}\n$1" >&3 40 | ``` 41 | 42 | This handler responds to any event written on a matching topic, and sends back an event on the same topic by replacing `in` by `out`. 43 | 44 | For instance, sending "hello world" to `example/echo/test/in` will send back "hello world" to the topic `example/echo/test/out`. 45 | 46 | ### example:timer:+:set 47 | 48 | ```bash 49 | #!/bin/bash 50 | 51 | # Kill concurrent run of ourselves 52 | echo "KILL $MQTT_TOPIC" >&3 53 | 54 | n=$1 55 | while [ $n -ge 0 ]; do 56 | sleep 1 57 | echo -e "PUB ${MQTT_TOPIC%*set}tick 0 ${#n}\n$n" >&3 58 | ((n--)) 59 | done 60 | ``` 61 | 62 | This handler sends a tick every second for `n` seconds when `n` is sent to a matching topic. Ticks are sent on the same topic with the last `set` component replaced by `tick`. 63 | 64 | Note that we introduced the `KILL` command here. A `KILL` followed by a topic, will kill all existing running commands that match the provided topic. The current process is never killed, even if the topic matches. 65 | 66 | ## Install 67 | 68 | From source: 69 | 70 | go get -u github.com/rs/moquette 71 | 72 | Using docker (assuming the `conf/` directory contains your handlers): 73 | 74 | docker run -it --rm -v $(pwd)/conf:/etc/moquette.d poitrus/moquette -broker tcp://:1883 75 | 76 | ## License 77 | 78 | All source code is licensed under the [MIT License](https://raw.github.com/rs/moquette/master/LICENSE). 79 | -------------------------------------------------------------------------------- /conf/example:echo:+:in: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # This handler responds to any event written on a matching topic, and sends back 4 | # an event on the same topic by replacing "in" by "out". 5 | # 6 | # For instance, sending "hello world" to example/echo/test/in will send back 7 | # "hello world" to the topic example/echo/test/out. 8 | 9 | echo -e "PUB ${MQTT_TOPIC%*in}out 0 ${#1}\n$1" >&3 10 | -------------------------------------------------------------------------------- /conf/example:timer:+:set: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # This handler sends a tick every second for n seconds when n is sent to a 4 | # matching topic. Ticks are sent on the same topic with the last "set" component 5 | # replaced by "tick". 6 | 7 | # Kill concurrent run of ourselves 8 | echo "KILL $MQTT_TOPIC" >&3 9 | 10 | n=$1 11 | while [ $n -ge 0 ]; do 12 | sleep 1 13 | echo -e "PUB ${MQTT_TOPIC%*set}tick 0 ${#n}\n$n" >&3 14 | ((n--)) 15 | done 16 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/rs/moquette 2 | 3 | require ( 4 | github.com/eclipse/paho.mqtt.golang v0.0.0-20180614102224-88c4622b8e24 5 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225 // indirect 6 | golang.org/x/sys v0.0.0-20180806192500-2be389f392cd 7 | ) 8 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/eclipse/paho.mqtt.golang v0.0.0-20180614102224-88c4622b8e24 h1:21LB+xEZf6xdOrp2JRw5Uongjp7wzGoJ99UnApIlUeg= 2 | github.com/eclipse/paho.mqtt.golang v0.0.0-20180614102224-88c4622b8e24/go.mod h1:H9keYFcgq3Qr5OUJm/JZI/i6U7joQ8SYLhZwfeOo6Ts= 3 | github.com/eclipse/paho.mqtt.golang v1.1.1 h1:iPJYXJLaViCshRTW/PSqImSS6HJ2Rf671WR0bXZ2GIU= 4 | github.com/eclipse/paho.mqtt.golang v1.1.1/go.mod h1:H9keYFcgq3Qr5OUJm/JZI/i6U7joQ8SYLhZwfeOo6Ts= 5 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225 h1:kNX+jCowfMYzvlSvJu5pQWEmyWFrBXJ3PBy10xKMXK8= 6 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 7 | golang.org/x/sys v0.0.0-20180806192500-2be389f392cd h1:KFYUs6SCkSktZ+xJWb5YbuSCJLLphbTsg0kvyirtlQ8= 8 | golang.org/x/sys v0.0.0-20180806192500-2be389f392cd/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 9 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "flag" 6 | "log" 7 | "os" 8 | "strconv" 9 | "time" 10 | 11 | mqtt "github.com/eclipse/paho.mqtt.golang" 12 | "github.com/rs/moquette/server" 13 | ) 14 | 15 | func main() { 16 | hostname, _ := os.Hostname() 17 | debug := flag.Bool("debug", false, "Turn on debugging") 18 | broker := flag.String("broker", "tcp://127.0.0.1:1883", "The full url of the mqtt broker to connect to ex: tcp://127.0.0.1:1883") 19 | clientID := flag.String("client-id", hostname+strconv.Itoa(time.Now().Second()), "A client id for the connection") 20 | username := flag.String("username", "", "A username to authenticate to the mqtt server") 21 | password := flag.String("password", "", "Password to match username") 22 | confDir := flag.String("conf", "/etc/moquette.d", "Path to the configuration director") 23 | sep := flag.String("sep", ":", "File name separator used for topic separator (/)") 24 | flag.Parse() 25 | 26 | if *debug { 27 | mqtt.DEBUG = log.New(os.Stderr, "", 0) 28 | } 29 | mqtt.ERROR = log.New(os.Stderr, "", 0) 30 | 31 | connOpts := mqtt.NewClientOptions(). 32 | AddBroker(*broker). 33 | SetClientID(*clientID). 34 | SetCleanSession(true). 35 | SetKeepAlive(2 * time.Second) 36 | if *username != "" { 37 | connOpts.SetUsername(*username) 38 | if *password != "" { 39 | connOpts.SetPassword(*password) 40 | } 41 | } 42 | tlsConfig := &tls.Config{InsecureSkipVerify: true, ClientAuth: tls.NoClientCert} 43 | connOpts.SetTLSConfig(tlsConfig) 44 | 45 | connOpts.SetOnConnectHandler(func(_ mqtt.Client) { 46 | log.Print("Connected") 47 | }) 48 | 49 | s := server.New(connOpts, *confDir, *sep) 50 | stop := make(chan struct{}) 51 | if err := s.Run(stop); err != nil { 52 | panic(err) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /router/router.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "errors" 5 | "io/ioutil" 6 | "path" 7 | "strings" 8 | 9 | "golang.org/x/sys/unix" 10 | ) 11 | 12 | var ErrNotFound = errors.New("not found") 13 | 14 | type Router struct { 15 | Dir string 16 | Sep string 17 | } 18 | 19 | func (r Router) Match(topic string) (string, error) { 20 | files, err := ioutil.ReadDir(r.Dir) 21 | if err != nil { 22 | return "", err 23 | } 24 | 25 | for _, file := range files { 26 | if !file.Mode().IsRegular() || file.Mode().Perm()&unix.S_IXUSR == 0 { 27 | // Skip non-executable files 28 | continue 29 | } 30 | 31 | route := strings.Replace(file.Name(), r.Sep, "/", -1) 32 | if route == topic || routeIncludesTopic(route, topic) { 33 | return path.Join(r.Dir, file.Name()), nil 34 | } 35 | } 36 | 37 | return "", ErrNotFound 38 | } 39 | 40 | func routeIncludesTopic(route, topic string) bool { 41 | return match(strings.Split(route, "/"), strings.Split(topic, "/")) 42 | } 43 | 44 | // match takes a slice of strings which represent the route being tested having been split on '/' 45 | // separators, and a slice of strings representing the topic string in the published message, similarly 46 | // split. 47 | // The function determines if the topic string matches the route according to the MQTT topic rules 48 | // and returns a boolean of the outcome 49 | func match(route []string, topic []string) bool { 50 | if len(route) == 0 { 51 | if len(topic) == 0 { 52 | return true 53 | } 54 | return false 55 | } 56 | 57 | if len(topic) == 0 { 58 | if route[0] == "#" { 59 | return true 60 | } 61 | return false 62 | } 63 | 64 | if route[0] == "#" { 65 | return true 66 | } 67 | 68 | if (route[0] == "+") || (route[0] == topic[0]) { 69 | return match(route[1:], topic[1:]) 70 | } 71 | return false 72 | } 73 | -------------------------------------------------------------------------------- /server/proto.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "strings" 8 | ) 9 | 10 | type protoReader struct { 11 | buf *bufio.Reader 12 | } 13 | 14 | type command interface { 15 | String() string 16 | } 17 | 18 | type event struct { 19 | Topic string 20 | QoS byte 21 | Payload []byte 22 | } 23 | 24 | func (ev event) String() string { 25 | return fmt.Sprintf("PUB %s %v: %s", ev.Topic, ev.QoS, ev.Payload) 26 | } 27 | 28 | type kill struct { 29 | Topic string 30 | } 31 | 32 | func (k kill) String() string { 33 | return fmt.Sprintf("KILL %s", k.Topic) 34 | } 35 | 36 | func newProtoReader(r io.Reader) protoReader { 37 | return protoReader{bufio.NewReader(r)} 38 | } 39 | 40 | func (p protoReader) Next() (command, error) { 41 | cmd, err := p.buf.ReadString(' ') 42 | if err != nil { 43 | return nil, err 44 | } 45 | cmd = strings.ToUpper(strings.TrimSpace(cmd)) 46 | switch cmd { 47 | case "PUB": 48 | return p.parsePub() 49 | case "KILL": 50 | return p.parseKill() 51 | default: 52 | return nil, fmt.Errorf("invalid command (%s)", cmd) 53 | } 54 | } 55 | 56 | func (p protoReader) parsePub() (ev event, err error) { 57 | if ev.Topic, err = p.buf.ReadString(' '); err != nil { 58 | return ev, fmt.Errorf("can't parse topic: %v", err) 59 | } 60 | ev.Topic = strings.TrimSpace(ev.Topic) 61 | if _, err := fmt.Fscanf(p.buf, "%d", &ev.QoS); err != nil { 62 | return ev, fmt.Errorf("can't parse QoS: %v", err) 63 | } 64 | if ev.QoS < 0 || ev.QoS > 3 { 65 | return ev, fmt.Errorf("invalid QoS: %v", ev.QoS) 66 | } 67 | if b, err := p.buf.ReadByte(); err != nil { 68 | return ev, err 69 | } else if b != ' ' { 70 | return ev, fmt.Errorf("expect space, got: %q", string(b)) 71 | } 72 | var l int 73 | if _, err := fmt.Fscanf(p.buf, "%d", &l); err != nil { 74 | return ev, fmt.Errorf("can't parse payload length: %v", err) 75 | } 76 | if b, err := p.buf.ReadByte(); err != nil { 77 | return ev, err 78 | } else if b != '\n' { 79 | return ev, fmt.Errorf("expect EOL, got: %q", string(b)) 80 | } 81 | ev.Payload = make([]byte, l) 82 | if n, err := p.buf.Read(ev.Payload); err != nil { 83 | return ev, fmt.Errorf("can't parse payload: %v", err) 84 | } else if n < l { 85 | return ev, fmt.Errorf("payload too short: expected %d, got %d", l, n) 86 | } 87 | if b, _ := p.buf.Peek(1); len(b) == 1 && b[0] == '\n' { 88 | // Clean optional return after payload 89 | p.buf.Discard(1) 90 | } 91 | return 92 | } 93 | 94 | func (p protoReader) parseKill() (k kill, err error) { 95 | if k.Topic, err = p.buf.ReadString('\n'); err != nil { 96 | return k, fmt.Errorf("can't parse topic: %v", err) 97 | } 98 | k.Topic = strings.TrimSpace(k.Topic) 99 | return 100 | } 101 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "os" 8 | "os/exec" 9 | "sync" 10 | 11 | mqtt "github.com/eclipse/paho.mqtt.golang" 12 | "github.com/rs/moquette/router" 13 | ) 14 | 15 | type Server struct { 16 | conf string 17 | sep string 18 | client mqtt.Client 19 | procs map[*os.Process]string // proc -> topic 20 | mu sync.RWMutex 21 | } 22 | 23 | func New(mqttOpts *mqtt.ClientOptions, confDir, sep string) *Server { 24 | s := &Server{ 25 | conf: confDir, 26 | sep: sep, 27 | procs: map[*os.Process]string{}, 28 | } 29 | 30 | messageHandler := func(_ mqtt.Client, msg mqtt.Message) { 31 | go s.handleMessage(msg) 32 | } 33 | mqttOpts.SetOnConnectHandler(func(c mqtt.Client) { 34 | if token := c.Subscribe("#", byte(0), messageHandler); token.Wait() && token.Error() != nil { 35 | panic(token.Error()) 36 | } 37 | }) 38 | s.client = mqtt.NewClient(mqttOpts) 39 | 40 | return s 41 | } 42 | 43 | func (s *Server) Run(stop chan struct{}) error { 44 | if token := s.client.Connect(); token.Wait() && token.Error() != nil { 45 | return token.Error() 46 | } 47 | 48 | <-stop 49 | 50 | return nil 51 | } 52 | 53 | func (s *Server) intputHandler(p *os.Process, r io.Reader) { 54 | proto := newProtoReader(r) 55 | for { 56 | cmd, err := proto.Next() 57 | if err != nil { 58 | if _, ok := err.(*os.PathError); !ok && err != io.EOF { 59 | log.Printf("invalid input: %v", err) 60 | } 61 | break 62 | } 63 | log.Print(cmd) 64 | switch t := cmd.(type) { 65 | case event: 66 | s.client.Publish(t.Topic, t.QoS, false, t.Payload) 67 | case kill: 68 | s.kill(t.Topic, p) 69 | } 70 | } 71 | } 72 | 73 | func (s *Server) handleMessage(msg mqtt.Message) { 74 | rt := router.Router{ 75 | Dir: s.conf, 76 | Sep: s.sep, 77 | } 78 | topic := msg.Topic() 79 | cmd, err := rt.Match(topic) 80 | if err == router.ErrNotFound || cmd == "" { 81 | return 82 | } 83 | if err != nil { 84 | log.Print("can't route message: ", err) 85 | return 86 | } 87 | r, w, err := os.Pipe() 88 | if err != nil { 89 | log.Print("can't create pipe: ", err) 90 | return 91 | } 92 | defer r.Close() 93 | defer w.Close() 94 | p := string(msg.Payload()) 95 | c := exec.Command(cmd, p) 96 | c.Dir = s.conf 97 | c.Stdout = os.Stdout 98 | c.Stderr = os.Stderr 99 | c.ExtraFiles = []*os.File{w} 100 | c.Env = []string{ 101 | fmt.Sprintf("MQTT_TOPIC=%s", msg.Topic()), 102 | fmt.Sprintf("MQTT_MSGID=%d", msg.MessageID()), 103 | } 104 | if err := c.Start(); err != nil { 105 | log.Printf("%s: %v", cmd, err) 106 | } 107 | go s.intputHandler(c.Process, r) 108 | log.Printf("executing %s %s (pid: %d)", cmd, p, c.Process.Pid) 109 | s.addProc(c.Process, topic) 110 | defer s.removeProc(c.Process) 111 | if err := c.Wait(); err != nil { 112 | log.Printf("%s: %v", cmd, err) 113 | } 114 | } 115 | 116 | func (s *Server) addProc(p *os.Process, topic string) { 117 | s.mu.Lock() 118 | defer s.mu.Unlock() 119 | s.procs[p] = topic 120 | } 121 | 122 | func (s *Server) removeProc(p *os.Process) { 123 | s.mu.Lock() 124 | defer s.mu.Unlock() 125 | delete(s.procs, p) 126 | } 127 | 128 | func (s *Server) kill(topic string, except *os.Process) { 129 | s.mu.RLock() 130 | defer s.mu.RUnlock() 131 | for p, t := range s.procs { 132 | if t == topic && (except == nil || except.Pid != p.Pid) { 133 | p.Kill() 134 | } 135 | } 136 | } 137 | --------------------------------------------------------------------------------