├── docs
├── assets
│ └── baby_yoda.jpg
├── about.md
├── configuration.md
├── source-build.md
├── index.md
├── docker-build.md
└── java.md
├── lib
├── mqtt
│ ├── conn_handler.go
│ ├── mqtt_base.go
│ ├── mqtt_handler.go
│ ├── server_context.go
│ └── server_context_test.go
├── auth
│ ├── ldap_auth_impl.go
│ └── provider.go
├── config
│ ├── parse.go
│ └── config.go
├── transports
│ ├── ws_server.go
│ └── tcp_server.go
├── persistence
│ ├── provider.go
│ ├── redis_provider.go
│ └── badger_provider.go
└── utils
│ ├── filters.go
│ └── filters_test.go
├── docker-compose.yml
├── Dockerfile
├── .github
├── workflows
│ ├── docs.yml
│ ├── docker.yml
│ ├── codeql-analysis.yml
│ └── go.yml
└── dependabot.yml
├── .gitignore
├── app
└── main.go
├── mkdocs.yml
├── LICENSE
├── go.mod
├── README.md
├── CODE_OF_CONDUCT.md
└── go.sum
/docs/assets/baby_yoda.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/c16a/hermes/HEAD/docs/assets/baby_yoda.jpg
--------------------------------------------------------------------------------
/docs/about.md:
--------------------------------------------------------------------------------
1 | Hermes is a tiny MQTT broker written in Go with a focus on minimalism.
2 |
3 | Hence, the minimal content here, too.
4 |
5 | [](https://github.com/c16a/hermes/blob/master/LICENSE)
6 |
--------------------------------------------------------------------------------
/lib/mqtt/conn_handler.go:
--------------------------------------------------------------------------------
1 | package mqtt
2 |
3 | import "net"
4 |
5 | func HandleMqttConnection(conn net.Conn, ctx *ServerContext) {
6 | handler := &MqttHandler{base: ctx, logger: ctx.logger}
7 |
8 | for true {
9 | handler.Handle(conn)
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.9"
2 | services:
3 | broker:
4 | build:
5 | context: .
6 | environment:
7 | CONFIG_FILE_PATH: "/tmp/hermes/config.json"
8 | volumes:
9 | - hermes_volume:/tmp/hermes
10 | ports:
11 | - 4000:4000
12 | - 5000:5000
13 | volumes:
14 | hermes_volume:
15 | external: true
16 |
--------------------------------------------------------------------------------
/docs/configuration.md:
--------------------------------------------------------------------------------
1 | Hermes can be configured via a custom JSON configuration, and the path can be passed over via the `CONFIG_FILE_PATH` environment variable.
2 | The current JSON schema to be adhered to, can be found at [**c16a/hermes:/config/config.go**](https://github.com/c16a/hermes/blob/master/config/config.go)
3 |
4 | When running on Docker or Kubernetes, this file should be mounted as a volume.
5 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM docker.io/golang:1.17 as builder
2 |
3 | WORKDIR /app
4 |
5 | ADD go.mod .
6 | ADD go.sum .
7 | RUN go mod download
8 |
9 | ADD . .
10 |
11 | ENV CGO_ENABLED=0
12 | RUN go build -ldflags="-s -w" -o binary github.com/c16a/hermes/app
13 |
14 | FROM scratch
15 | WORKDIR /app
16 |
17 | ENV CONFIG_FILE_PATH="/var/config.json"
18 |
19 | COPY --from=builder /app/binary .
20 | CMD ["/app/binary"]
21 |
--------------------------------------------------------------------------------
/.github/workflows/docs.yml:
--------------------------------------------------------------------------------
1 | name: Docs
2 | on:
3 | push:
4 | branches:
5 | - master
6 | - main
7 | jobs:
8 | deploy:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v2.3.4
12 | - uses: actions/setup-python@v2.2.2
13 | with:
14 | python-version: 3.x
15 | - run: pip install mkdocs-material
16 | - run: mkdocs gh-deploy --force
17 |
--------------------------------------------------------------------------------
/lib/mqtt/mqtt_base.go:
--------------------------------------------------------------------------------
1 | package mqtt
2 |
3 | import (
4 | "github.com/eclipse/paho.golang/packets"
5 | "io"
6 | )
7 |
8 | type MqttBase interface {
9 | AddClient(io.Writer, *packets.Connect) (reasonCode byte, sessionExists bool, maxQos byte)
10 | Disconnect(io.Writer, *packets.Disconnect)
11 | Publish(*packets.Publish)
12 | Subscribe(io.Writer, *packets.Subscribe) []byte
13 | Unsubscribe(io.Writer, *packets.Unsubscribe) []byte
14 |
15 | ReservePacketID(io.Writer, *packets.Publish) error
16 | FreePacketID(io.Writer, *packets.Pubrel) error
17 | }
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by .ignore support plugin (hsz.mobi)
2 | ### Go template
3 | # Binaries for programs and plugins
4 | *.exe
5 | *.exe~
6 | *.dll
7 | *.so
8 | *.dylib
9 |
10 | # Test binary, built with `go test -conn`
11 | *.test
12 |
13 | # Output of the go coverage tool, specifically when used with LiteIDE
14 | *.out
15 |
16 | # Dependency directories (remove the comment below to include it)
17 | # vendor/
18 |
19 | ### Example user template template
20 | ### Example user template
21 |
22 | # IntelliJ project files
23 | .idea
24 | *.iml
25 | out
26 | gen
27 |
28 | config.json
29 |
--------------------------------------------------------------------------------
/lib/auth/ldap_auth_impl.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "fmt"
5 | "github.com/c16a/hermes/lib/config"
6 | "github.com/go-ldap/ldap/v3"
7 | )
8 |
9 | type LdapAuthImpl struct {
10 | config *config.Config
11 | }
12 |
13 | func (impl *LdapAuthImpl) Validate(username string, password string) error {
14 | authConfig := impl.config.Server.Auth
15 |
16 | l, err := ldap.Dial("tcp", fmt.Sprintf("%s:%d", authConfig.LdapHost, authConfig.LdapPort))
17 | if err != nil {
18 | return err
19 | }
20 |
21 | cn := fmt.Sprintf("cn=%s,%s", username, authConfig.LdapDn)
22 | return l.Bind(cn, password)
23 | }
24 |
--------------------------------------------------------------------------------
/lib/auth/provider.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "errors"
5 | "github.com/c16a/hermes/lib/config"
6 | )
7 |
8 | type AuthorisationProvider interface {
9 | Validate(string, string) error
10 | }
11 |
12 | func FetchProviderFromConfig(config *config.Config) (provider AuthorisationProvider, err error) {
13 | authConfig := config.Server.Auth
14 |
15 | if authConfig == nil || len(authConfig.Type) == 0 {
16 | return
17 | }
18 |
19 | switch authConfig.Type {
20 | case "ldap":
21 | provider = &LdapAuthImpl{config: config}
22 | break
23 | default:
24 | err = errors.New("no valid auth provider found")
25 | }
26 |
27 | return
28 | }
29 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "gomod" # See documentation for possible values
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "daily"
12 | - package-ecosystem: "github-actions" # See documentation for possible values
13 | directory: "/" # Location of package manifests
14 | schedule:
15 | interval: "daily"
16 |
--------------------------------------------------------------------------------
/app/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "github.com/c16a/hermes/lib/config"
5 | "github.com/c16a/hermes/lib/mqtt"
6 | "github.com/c16a/hermes/lib/transports"
7 | "go.uber.org/zap"
8 | "log"
9 | "os"
10 | )
11 |
12 | func main() {
13 |
14 | configFilePath := os.Getenv("CONFIG_FILE_PATH")
15 |
16 | logger, err := zap.NewProduction()
17 | if err != nil {
18 | log.Fatal(err)
19 | }
20 |
21 | serverConfig, err := config.ParseConfig(configFilePath)
22 | if err != nil {
23 | log.Fatal(err)
24 | }
25 |
26 | ctx, err := mqtt.NewServerContext(serverConfig, logger)
27 | if err != nil {
28 | log.Fatal(err)
29 | }
30 |
31 | go transports.StartWebSocketServer(serverConfig, ctx, logger)
32 | transports.StartTcpServer(serverConfig, ctx, logger)
33 | }
34 |
--------------------------------------------------------------------------------
/docs/source-build.md:
--------------------------------------------------------------------------------
1 | Hermes can be built from source on Linux, Windows, or macOS.
2 |
3 | ## Prerequisites
4 | - Git
5 | - Golang 1.15 or newer
6 |
7 | ## Building
8 | ```shell
9 | git clone https://github.com/c16a/hermes.git
10 | cd hermes
11 | go build -ldflags="-s -w" -o binary github.com/c16a/hermes/app
12 | ```
13 |
14 | ### Cross compiling
15 | To cross compile the Hermes binary to a different architecture or operating system,
16 | the `GOOS` and `GOARCH` environment variables can be used.
17 | ```shell
18 | # List all available os/arch combinations for cross compiling
19 | go tool dist list
20 |
21 | # To compile the binary for Linux ARM 64-bit, use the below
22 | GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o binary_amd64 github.com/c16a/hermes/app
23 | ```
24 |
--------------------------------------------------------------------------------
/lib/config/parse.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "encoding/json"
5 | "io/ioutil"
6 | )
7 |
8 | // ParseConfig parses a file path and returns a config
9 | //
10 | // If the file is remote, it is downloaded via HTTP (proxy settings are respected)
11 | // If file is local, is it opened in readonly mode.
12 | func ParseConfig(configFilePath string) (*Config, error) {
13 | fileBytes, err := readLocalFile(configFilePath)
14 | if err != nil {
15 | return nil, err
16 | }
17 | return jsonParser(fileBytes)
18 | }
19 | func readLocalFile(filePath string) ([]byte, error) {
20 | return ioutil.ReadFile(filePath)
21 | }
22 |
23 | func jsonParser(fileBytes []byte) (*Config, error) {
24 | var config Config
25 | err := json.Unmarshal(fileBytes, &config)
26 | return &config, err
27 | }
28 |
--------------------------------------------------------------------------------
/lib/transports/ws_server.go:
--------------------------------------------------------------------------------
1 | package transports
2 |
3 | import (
4 | "fmt"
5 | "github.com/c16a/hermes/lib/config"
6 | "github.com/c16a/hermes/lib/mqtt"
7 | "github.com/gorilla/websocket"
8 | "go.uber.org/zap"
9 | "log"
10 | "net/http"
11 | )
12 |
13 | func StartWebSocketServer(serverConfig *config.Config, ctx *mqtt.ServerContext, logger *zap.Logger) {
14 | upgrader := websocket.Upgrader{}
15 |
16 | httpAddr := serverConfig.Server.HttpAddress
17 | http.HandleFunc("/socket", func(writer http.ResponseWriter, request *http.Request) {
18 | c, err := upgrader.Upgrade(writer, request, nil)
19 | if err != nil {
20 | log.Print("upgrade:", err)
21 | return
22 | }
23 | defer c.Close()
24 |
25 | go mqtt.HandleMqttConnection(c.UnderlyingConn(), ctx)
26 | })
27 |
28 | logger.Info(fmt.Sprintf("Starting Websocket server on %s", httpAddr))
29 | log.Fatal(http.ListenAndServe(httpAddr, nil))
30 | }
31 |
--------------------------------------------------------------------------------
/.github/workflows/docker.yml:
--------------------------------------------------------------------------------
1 | name: Docker
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 |
8 | jobs:
9 | build_image:
10 | name: Build Image
11 | runs-on: ubuntu-20.04
12 |
13 | steps:
14 | - name: Check out code
15 | uses: actions/checkout@v2.3.4
16 |
17 | - name: Setup buildx
18 | uses: docker/setup-buildx-action@v1.3.0
19 |
20 | - name: Login to GitHub Container Registry
21 | uses: docker/login-action@v1.9.0
22 | with:
23 | registry: ghcr.io
24 | username: $GITHUB_ACTOR
25 | password: ${{ secrets.CR_PAT }}
26 |
27 | - name: Build and push
28 | uses: docker/build-push-action@v2.4.0
29 | with:
30 | context: .
31 | file: ./Dockerfile
32 | platforms: linux/amd64,linux/arm64
33 | username: $GITHUB_ACTOR
34 | password: ${{ secrets.CR_PAT }}
35 | push: true
36 | tags: |
37 | ghcr.io/c16a/hermes/hermes:latest
38 |
--------------------------------------------------------------------------------
/lib/persistence/provider.go:
--------------------------------------------------------------------------------
1 | package persistence
2 |
3 | import (
4 | "bytes"
5 | "encoding/gob"
6 | "github.com/eclipse/paho.golang/packets"
7 | )
8 |
9 | type Provider interface {
10 | SaveForOfflineDelivery(clientId string, publish *packets.Publish) error
11 | GetMissedMessages(clientId string) ([]*packets.Publish, error)
12 |
13 | ReservePacketID(clientID string, packetID uint16) error
14 | FreePacketID(clientID string, packetID uint16) error
15 | CheckForPacketIdReuse(clientID string, packetID uint16) (bool, error)
16 | }
17 |
18 | func getBytes(bundle interface{}) ([]byte, error) {
19 | var buf bytes.Buffer
20 | enc := gob.NewEncoder(&buf)
21 | err := enc.Encode(bundle)
22 | if err != nil {
23 | return nil, err
24 | }
25 | return buf.Bytes(), nil
26 | }
27 |
28 | func getPublishPacket(src []byte) (*packets.Publish, error) {
29 | buf := bytes.NewBuffer(src)
30 | decoder := gob.NewDecoder(buf)
31 |
32 | var publish packets.Publish
33 | err := decoder.Decode(&publish)
34 | return &publish, err
35 | }
--------------------------------------------------------------------------------
/mkdocs.yml:
--------------------------------------------------------------------------------
1 | site_name: Hermes
2 | repo_name: c16a/hermes
3 | repo_url: https://github.com/c16a/hermes
4 | theme:
5 | name: material
6 | features:
7 | - navigation.instant
8 | - navigation.tabs
9 | - navigation.sections
10 | - navigation.expand
11 | icon:
12 | repo: fontawesome/brands/github
13 | font:
14 | text: IBM Plex Sans
15 | code: JetBrains Mono
16 |
17 | nav:
18 | - Home: index.md
19 | - Developer Guide:
20 | - 'Running Locally':
21 | - 'Using Docker': docker-build.md
22 | - 'Building from source': source-build.md
23 | - 'Configuration': configuration.md
24 | - User Guide:
25 | - 'Java': java.md
26 | - About: about.md
27 |
28 | markdown_extensions:
29 | - pymdownx.highlight
30 | - pymdownx.inlinehilite
31 | - pymdownx.superfences
32 | - attr_list
33 |
34 | extra:
35 | social:
36 | - icon: fontawesome/brands/twitter
37 | link: https://twitter.com/iamunukutla
38 | - icon: fontawesome/brands/github
39 | link: https://github.com/c16a
40 |
41 | copyright: Copyright © 2020 Chaitanya Munukutla
42 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Chaitanya Munukutla
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/lib/utils/filters.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "errors"
5 | "strings"
6 | )
7 |
8 | func GetTopicInfo(topicFilter string) (levels []string, isShared bool, shareName string, err error) {
9 | levels = strings.Split(topicFilter, "/")
10 |
11 | if strings.EqualFold("$share", levels[0]) {
12 | if len(levels) >= 3 {
13 | return levels[2:], true, levels[1], nil
14 | } else {
15 | return nil, false, "", errors.New("invalid shared subscription")
16 | }
17 | }
18 |
19 | return levels, false, "", nil
20 | }
21 |
22 | func TopicMatches(topic string, topicFilter string) (matches bool, isShared bool, shareName string) {
23 | levels, isShared, shareName, err := GetTopicInfo(topicFilter)
24 | if err != nil {
25 | return false, false, ""
26 | }
27 |
28 | incomingTopicChunks := strings.Split(topic, "/")
29 |
30 | for index, level := range levels {
31 | if strings.EqualFold(level, "#") {
32 | return true, isShared, shareName
33 | }
34 | if strings.EqualFold(level, "+") {
35 | continue
36 | } else {
37 | if !strings.EqualFold(level, incomingTopicChunks[index]) {
38 | return false, false, ""
39 | }
40 | }
41 | }
42 |
43 | return true, isShared, shareName
44 | }
45 |
--------------------------------------------------------------------------------
/lib/transports/tcp_server.go:
--------------------------------------------------------------------------------
1 | package transports
2 |
3 | import (
4 | "crypto/tls"
5 | "fmt"
6 | "github.com/c16a/hermes/lib/config"
7 | "github.com/c16a/hermes/lib/mqtt"
8 | "go.uber.org/zap"
9 | "net"
10 | )
11 |
12 | func StartTcpServer(serverConfig *config.Config, ctx *mqtt.ServerContext, logger *zap.Logger) {
13 | var listener net.Listener
14 | var listenerErr error
15 |
16 | tcpAddress := serverConfig.Server.TcpAddress
17 |
18 | tlsConfigFromFile := serverConfig.Server.Tls
19 | if tlsConfigFromFile == nil {
20 | listener, listenerErr = net.Listen("tcp", tcpAddress)
21 | } else {
22 | if len(tlsConfigFromFile.CertFile) == 0 || len(tlsConfigFromFile.KeyFile) == 0 {
23 | // TCP config invalid - don't start TCP server
24 | return
25 | }
26 | cert, err := tls.LoadX509KeyPair(tlsConfigFromFile.CertFile, tlsConfigFromFile.KeyFile)
27 | if err != nil {
28 | // Could not read certs - don't start TCP server
29 | return
30 | }
31 | tlsConfig := tls.Config{Certificates: []tls.Certificate{cert}}
32 | listener, listenerErr = tls.Listen("tcp", tcpAddress, &tlsConfig)
33 | }
34 |
35 | if listenerErr != nil {
36 | return
37 | }
38 | defer listener.Close()
39 |
40 | logger.Info(fmt.Sprintf("Starting TCP server on %s", tcpAddress))
41 |
42 | for {
43 | conn, err := listener.Accept()
44 | if err != nil {
45 | return
46 | }
47 | go mqtt.HandleMqttConnection(conn, ctx)
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | # This, is Hermes
2 |
3 | Hermes is a tiny MQTT broker written in Go.
4 | It is inspired by several mature messaging systems such as NATS, Kafka, ActiveMQ etc.
5 |
6 | ## Vision
7 |
8 | #### Messaging should be easy
9 | Developers should be able to onboard Hermes and start writing code without the
10 | need to unlearn their current messaging experience.
11 | Hermes will be cloud compatible, but you don't need to have Kubernetes running.
12 | You will be able to run Hermes anywhere from a Raspberry Pi, to a traditional Linux Desktop,
13 | a gigantic public cloud machine, an IBM-Z server, and of course, Kubernetes.
14 |
15 | #### Messaging should be based on open standards
16 | When messaging systems are built on open standards, it grows the ecosystem instead of dividing it. Hermes will always be based on multiple open standards and transports.
17 |
18 |
19 | #### Knowledge is best when shared
20 | Hermes was born out of boredom during the COVID-19 pandemic, and the initial directions were discussed over a [**Reddit post**](https://www.reddit.com/r/golang/comments/kisutt/tiny_message_broker_written_in_go_nothing_serious).
21 | All discussions, and development are done on GitHub. There will never be an *enterprise*, or *premium* flavor of Hermes.
22 | It is however, MIT Licensed - you are free to use it however you wish to.
23 |
24 | #### Baby Yoda eating cookies is cute
25 | {: loading=lazy }
26 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/c16a/hermes
2 |
3 | go 1.17
4 |
5 | require (
6 | github.com/dgraph-io/badger/v2 v2.2007.4
7 | github.com/eclipse/paho.golang v0.10.0
8 | github.com/go-ldap/ldap/v3 v3.4.1
9 | github.com/go-redis/redis/v8 v8.11.3
10 | github.com/gorilla/websocket v1.4.2
11 | github.com/satori/go.uuid v1.2.0
12 | go.uber.org/zap v1.19.1
13 | )
14 |
15 | require (
16 | github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c // indirect
17 | github.com/cespare/xxhash v1.1.0 // indirect
18 | github.com/cespare/xxhash/v2 v2.1.1 // indirect
19 | github.com/dgraph-io/ristretto v0.0.3-0.20200630154024-f66de99634de // indirect
20 | github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 // indirect
21 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
22 | github.com/dustin/go-humanize v1.0.0 // indirect
23 | github.com/go-asn1-ber/asn1-ber v1.5.1 // indirect
24 | github.com/golang/protobuf v1.5.2 // indirect
25 | github.com/golang/snappy v0.0.3 // indirect
26 | github.com/klauspost/compress v1.12.3 // indirect
27 | github.com/pkg/errors v0.8.1 // indirect
28 | go.uber.org/atomic v1.7.0 // indirect
29 | go.uber.org/multierr v1.6.0 // indirect
30 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect
31 | golang.org/x/net v0.0.0-20210428140749-89ef3d95e781 // indirect
32 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
33 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007 // indirect
34 | google.golang.org/protobuf v1.26.0 // indirect
35 | )
36 |
--------------------------------------------------------------------------------
/lib/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | // Config is the root config
4 | type Config struct {
5 | Server *Server `json:"server,omitempty" yaml:"server,omitempty"`
6 | }
7 |
8 | // Server stores all server related configuration
9 | type Server struct {
10 | Tls *Tls `json:"tls" yaml:"tls"`
11 | TcpAddress string `json:"tcp,omitempty" yaml:"tcp,omitempty"`
12 | HttpAddress string `json:"http,omitempty" yaml:"http,omitempty"`
13 | MaxQos byte `json:"max_qos,omitempty" yaml:"max_qos,omitempty"`
14 | Auth *Auth `json:"auth,omitempty" yaml:"auth,omitempty"`
15 | Persistence *Persistence `json:"persistence,omitempty" yaml:"persistence,omitempty"`
16 | }
17 |
18 | // Tls stores the TLS config for the server
19 | type Tls struct {
20 | CertFile string `json:"cert,omitempty" yaml:"cert,omitempty"`
21 | KeyFile string `json:"key,omitempty" yaml:"key,omitempty"`
22 | }
23 |
24 | type Auth struct {
25 | Type string `json:"type,omitempty" yaml:"type,omitempty"`
26 | LdapHost string `json:"ldap_host,omitempty" yaml:"ldap_host,omitempty"`
27 | LdapPort int `json:"ldap_port,omitempty" yaml:"ldap_port,omitempty"`
28 | LdapDn string `json:"ldap_dn,omitempty" yaml:"ldap_dn,omitempty"`
29 | }
30 |
31 | type Badger struct {
32 | Path string `json:"path,omitempty"`
33 | MaxTableSize int64 `json:"max_table_size,omitempty"`
34 | NumTables int `json:"num_tables,omitempty"`
35 | }
36 |
37 | type Redis struct {
38 | Url string `json:"url" yaml:"url"`
39 | Password string `json:"password" yaml:"password"`
40 | }
41 |
42 | type Persistence struct {
43 | Type string `json:"type" yaml:"type"`
44 |
45 | Badger *Badger `json:"badger" yaml:"badger"`
46 | Redis *Redis `json:"redis" yaml:"redis"`
47 | }
48 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Hermes
2 |
3 | Hermes is a tiny MQTT compatible broker written in Go.
4 |
5 | [](https://github.com/c16a/hermes/workflows/Go/badge.svg)
6 |
7 | [](https://github.com/c16a/hermes/workflows/CodeQL/badge.svg)
8 |
9 | [](https://goreportcard.com/report/github.com/c16a/hermes)
10 |
11 | [](https://lgtm.com/projects/g/c16a/hermes/alerts/)
12 |
13 | The goals of the project are as below
14 |
15 | - Easy to compile, and run
16 | - Tiny footprint
17 | - Extensible
18 | - Adhering to standards
19 |
20 | ## Current features
21 |
22 | This is in no way ready to be consumed. This is a project which arose out of my boredom during COVID-19, and general
23 | issues whilst working with other production ready brokers such as ActiveMQ, Solace, NATS etc.
24 |
25 | - [x] CONNECT
26 | - [x] PUBLISH, PUBACK
27 | - [x] SUBSCRIBE, SUBACK
28 | - [x] DISCONNECT
29 | - [x] Persistent sessions
30 | - [x] QoS 2 support
31 | - [x] Offline messages
32 | - [ ] Wildcard subscriptions
33 | - [ ] Shared Subscriptions
34 | - [ ] Extended authentication
35 | - [ ] MQTT over WebSocket
36 | - [ ] Clustering
37 |
38 | ## Usage
39 |
40 | Any compatible MQTT client library can be used to interact with the broker
41 |
42 | - Java ([eclipse/paho.mqtt.java](https://github.com/eclipse/paho.mqtt.java))
43 | - Go ([eclipse/paho.golang](https://github.com/eclipse/paho.golang))
44 | - Other clients can be found [here](https://github.com/eclipse?q=paho&type=&language=)
45 |
46 | ## Planned features
47 |
48 | The following are some features from the top of my head which I will work on
49 |
50 | - Support for more transports such as WebSocket, gRPC, Rsocket(?)
51 | - Support for clustering
52 | - Authentication & extensible middleware
53 | - Message Persistence
54 |
55 | ## Contributing
56 |
57 | Fork it, give it a spin, and let me know!
58 |
59 |
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | push:
16 | branches: [ master ]
17 | pull_request:
18 | # The branches below must be a subset of the branches above
19 | branches: [ master ]
20 | schedule:
21 | - cron: '18 14 * * 4'
22 |
23 | jobs:
24 | analyze:
25 | name: Analyze
26 | runs-on: ubuntu-latest
27 |
28 | strategy:
29 | fail-fast: false
30 | matrix:
31 | language: [ 'go' ]
32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
33 | # Learn more:
34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
35 |
36 | steps:
37 | - name: Checkout repository
38 | uses: actions/checkout@v2.3.4
39 |
40 | # Initializes the CodeQL tools for scanning.
41 | - name: Initialize CodeQL
42 | uses: github/codeql-action/init@v1
43 | with:
44 | languages: ${{ matrix.language }}
45 | # If you wish to specify custom queries, you can do so here or in a config file.
46 | # By default, queries listed here will override any specified in a config file.
47 | # Prefix the list here with "+" to use these queries and those in the config file.
48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main
49 |
50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
51 | # If this step fails, then you should remove it and run the build manually (see below)
52 | - name: Autobuild
53 | uses: github/codeql-action/autobuild@v1
54 |
55 | # ℹ️ Command-line programs to run using the OS shell.
56 | # 📚 https://git.io/JvXDl
57 |
58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
59 | # and modify them (or add more) to build your code if your project
60 | # uses a compiled language
61 |
62 | #- run: |
63 | # make bootstrap
64 | # make release
65 |
66 | - name: Perform CodeQL Analysis
67 | uses: github/codeql-action/analyze@v1
68 |
--------------------------------------------------------------------------------
/docs/docker-build.md:
--------------------------------------------------------------------------------
1 | Hermes uses a multi stage docker build for hermetic builds, while creating a minimal image. Hence, please ensure you use
2 | Docker v17.05 or newer.
3 |
4 | ```shell
5 | git clone https://github.com/c16a/hermes.git
6 | cd hermes
7 | docker build -t hermes-app .
8 | ```
9 |
10 | ### Running the image
11 |
12 | ```shell
13 | docker run -p 4000:4000 -v $pwd/config.json:/app/config.json hermes-app
14 | ```
15 |
16 | The above example assumes that the TCP server has been configured to listen on port 4000. In case that is configured to
17 | another port, please configure the docker exposed port accordingly.
18 |
19 | #### SELinux policies
20 |
21 | When using Docker on a host with SELinux enabled, the container is denied access to certain parts of host file system
22 | unless it is run in privileged mode. To resolve this, you can use a named volume
23 |
24 | ```shell
25 | # Create a docker volume and map it to /tmp/hermes on the host
26 | docker volume create --driver local --opt type=none --opt device=/tmp/hermes --opt o=bind hermes_volume
27 |
28 | # Ensure /tmp/hermes/config.json has the required broker configuration
29 | # Use the above created hermes_volume to mount the config file into the container
30 | docker run -p 4000:4000 -e CONFIG_FILE_PATH=/tmp/hermes/config.json --mount source=hermes_volume,target=/tmp/hermes hermes
31 | ```
32 |
33 | Please note that however, you place your `config.json` in the `/tmp` directory, SELinux does not restrict you access
34 | when you use a direct volume mapping.
35 |
36 | ```shell
37 | # This won't work with SELinux enabled
38 | docker run -p 4000:4000 -e CONFIG_FILE_PATH=/tmp/hermes/config.json -v /home/user/config.json:/tmp/hermes/config.json hermes
39 |
40 | # This will work
41 | docker run -p 4000:4000 -e CONFIG_FILE_PATH=/tmp/hermes/config.json -v /tmp/hermes/config.json:/tmp/hermes/config.json hermes
42 | ```
43 |
44 | The [Configuration](configuration.md) section has more details on which attributes of the broker can be configured.
45 |
46 | ### Running in Compose mode
47 |
48 | Create the named volume `hermes_volume`.
49 |
50 | ```shell
51 | # Create a docker volume and map it to /tmp/hermes on the host
52 | docker volume create --driver local --opt type=none --opt device=/tmp/hermes --opt o=bind hermes_volume
53 | ```
54 |
55 | Reference the named volume for the service
56 |
57 | ```yaml
58 | version: "3.9"
59 | services:
60 | broker:
61 | build:
62 | context: .
63 | environment:
64 | CONFIG_FILE_PATH: "/tmp/hermes/config.json"
65 | volumes:
66 | - hermes_volume:/tmp/hermes
67 | ports:
68 | - 4000:4000
69 | - 5000:5000
70 | volumes:
71 | hermes_volume:
72 | external: true
73 | ```
74 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity and expression,
9 | level of experience, education, socio-economic status, nationality, personal
10 | appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at chaitanya.m61292@gmail.com. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
72 |
73 | [homepage]: https://www.contributor-covenant.org
74 |
75 | For answers to common questions about this code of conduct, see
76 | https://www.contributor-covenant.org/faq
77 |
--------------------------------------------------------------------------------
/lib/persistence/redis_provider.go:
--------------------------------------------------------------------------------
1 | package persistence
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "github.com/c16a/hermes/lib/config"
8 | "github.com/eclipse/paho.golang/packets"
9 | "github.com/go-redis/redis/v8"
10 | "go.uber.org/zap"
11 | "time"
12 | )
13 |
14 | type RedisProvider struct {
15 | client *redis.Client
16 | }
17 |
18 | func NewRedisProvider(config *config.Config, logger *zap.Logger) (Provider, error) {
19 | offlineConfig := config.Server.Persistence.Redis
20 |
21 | rdb := redis.NewClient(&redis.Options{
22 | Addr: offlineConfig.Url,
23 | Password: offlineConfig.Password,
24 | DB: 0,
25 | })
26 |
27 | err := rdb.Echo(context.Background(), "HELLO").Err()
28 | if err != nil {
29 | logger.Error("Could not connect to redis persistence provider", zap.Error(err))
30 | return nil, err
31 | } else {
32 | logger.Info("Connected to redis persistence provider")
33 | }
34 | return &RedisProvider{client: rdb}, nil
35 | }
36 |
37 | func (r *RedisProvider) SaveForOfflineDelivery(clientId string, publish *packets.Publish) error {
38 | _, err := r.client.TxPipelined(context.Background(), func(pipeliner redis.Pipeliner) error {
39 | key := fmt.Sprintf("urn:messages:%s", clientId)
40 |
41 | publishBytes, err := getBytes(publish)
42 | if err != nil {
43 | return err
44 | }
45 | pipeliner.LPush(context.Background(), key, publishBytes)
46 |
47 | // Set expiry
48 | if publish.Properties != nil && publish.Properties.MessageExpiry != nil {
49 | pipeliner.Expire(context.Background(), key, time.Duration(int(*publish.Properties.MessageExpiry))*time.Second)
50 | }
51 | return nil
52 | })
53 | return err
54 | }
55 |
56 | func (r *RedisProvider) GetMissedMessages(clientId string) ([]*packets.Publish, error) {
57 | publishPackets := make([]*packets.Publish, 0)
58 | key := fmt.Sprintf("urn:messages:%s", clientId)
59 |
60 | // Get the length of the list
61 | length, err := r.client.LLen(context.Background(), key).Result()
62 | if err != nil {
63 | return nil, err
64 | }
65 |
66 | // Pop everything in the list
67 | payloads, err := r.client.LPopCount(context.Background(), key, int(length)).Result()
68 | if err != nil {
69 | return nil, err
70 | }
71 |
72 | for _, payload := range payloads {
73 | payloadBytes := []byte(payload)
74 | publishPacket, err := getPublishPacket(payloadBytes)
75 | if err != nil {
76 | continue
77 | }
78 | publishPackets = append(publishPackets, publishPacket)
79 | }
80 | return publishPackets, err
81 |
82 | }
83 |
84 | func (r *RedisProvider) ReservePacketID(clientID string, packetID uint16) error {
85 | _, err := r.client.TxPipelined(context.Background(), func(pipeliner redis.Pipeliner) error {
86 | key := fmt.Sprintf("urn:packets:%s:%d", clientID, packetID)
87 | pipeliner.Set(context.Background(), key, PacketReserved, 24*time.Hour)
88 | return nil
89 | })
90 | return err
91 | }
92 |
93 | func (r *RedisProvider) FreePacketID(clientID string, packetID uint16) error {
94 | _, err := r.client.TxPipelined(context.Background(), func(pipeliner redis.Pipeliner) error {
95 | key := fmt.Sprintf("urn:packets:%s:%d", clientID, packetID)
96 | pipeliner.Del(context.Background(), key)
97 | return nil
98 | })
99 | return err
100 | }
101 |
102 | func (r *RedisProvider) CheckForPacketIdReuse(clientID string, packetID uint16) (bool, error) {
103 | reuseFlag := false
104 | _, err := r.client.TxPipelined(context.Background(), func(pipeliner redis.Pipeliner) error {
105 | key := fmt.Sprintf("urn:packets:%s:%d", clientID, packetID)
106 | resBytes, err := pipeliner.Get(context.Background(), key).Bytes()
107 | if err != nil {
108 | return err
109 | }
110 | if resBytes[0] == PacketReserved {
111 | reuseFlag = true
112 | } else {
113 | return errors.New("some weird error")
114 | }
115 | return nil
116 | })
117 | return reuseFlag, err
118 | }
119 |
--------------------------------------------------------------------------------
/docs/java.md:
--------------------------------------------------------------------------------
1 | Hermes is a MQTT v5.0 compatible broker, so any compatible Java library can be used.
2 |
3 | The widely used Eclipse Paho library can be imported into any Maven or Gradle project.
4 |
5 | ```xml
6 |
7 |
8 |
9 | Eclipse Paho Repo
10 | https://repo.eclipse.org/content/repositories/paho-releases/
11 |
12 |
13 | ```
14 |
15 | ```xml
16 |
17 |
18 |
19 | org.eclipse.paho
20 | org.eclipse.paho.mqttv5.client
21 | 1.2.5
22 |
23 |
24 | ```
25 |
26 | For Gradle repositories, use the below
27 |
28 | ```groovy
29 | // Groovy script
30 | repositories {
31 | maven {
32 | url "https://repo.eclipse.org/content/repositories/paho-releases/"
33 | }
34 | }
35 | ```
36 |
37 | ```kotlin
38 | // Kotlin script
39 | repositories {
40 | maven {
41 | url = uri("https://repo.eclipse.org/content/repositories/paho-releases/")
42 | }
43 | }
44 | ```
45 |
46 | ### Connecting to MQTT broker
47 |
48 | ```java
49 | var persistence = new MemoryPersistence();
50 | var client = new MqttClient(broker, clientID, persistence);
51 |
52 | var connOpts = new MqttConnectionOptions();
53 | // Setting clean start to "false" enables the client
54 | // to receive offline messages send while it was disconnected.
55 | connOpts.setCleanStart(false);
56 |
57 | client.connect(connOpts);
58 | ```
59 |
60 | ### Publishing messages
61 |
62 | ```java
63 | // Ensure client has already connected
64 | var content = "Hello World";
65 | var topic = "my-topic";
66 | var message = new MqttMessage(content.getBytes());
67 | client.publish(topic,message);
68 | ```
69 |
70 | ### Subscribing to incoming messages
71 |
72 | ```java
73 | // Do this before connecting
74 | client.setCallback(new MqttCallback(){
75 | @Override
76 | public void messageArrived(String topic, MqttMessage message) throws Exception{
77 | // Do something awesome
78 | }
79 |
80 | // other implemented methods
81 | });
82 | client.connect(connOpts);
83 |
84 | // Provide a topic and Quality of Service (QoS)
85 | client.subscribe("my-topic", 0);
86 | ```
87 |
88 | ### Closing the connection
89 |
90 | ```java
91 | client.disconnect();
92 | ```
93 |
94 | ## Spring Integration
95 |
96 | Spring Integration provides inbound and outbound channel adapters to support the MQTT protocol.
97 |
98 | The following dependencies can be used for Maven and Gradle respectively
99 |
100 | ```xml
101 |
102 | org.springframework.integration
103 | spring-integration-mqtt
104 | 5.4.2
105 |
106 | ```
107 |
108 | ```groovy
109 | compile "org.springframework.integration:spring-integration-mqtt:5.4.2"
110 | ```
111 |
112 | ### Inbound Channel Adapters
113 | Inbound adapters allow Spring applications to subscribe to topics and respond to incoming MQTT messages.
114 | ```java
115 | @Bean
116 | public IntegrationFlow mqttInbound() {
117 | var broker = "tcp://localhost:1883";
118 | var clientID = "client-id";
119 | var topic = "my-topic";
120 | var adapter = new MqttPahoMessageDrivenChannelAdapter(broker, clientID, topic);
121 | return IntegrationFlows.from(adapter).handle(m -> handleMsg(m)).get();
122 | }
123 |
124 | public void handleMsg(MqttMessage message) {
125 | // Do something awesome
126 | }
127 | ```
128 | ### Outbound Channel Adapters
129 | Inbound adapters allow Spring applications to publish MQTT messages onto topics.
130 | ```java
131 | @Bean
132 | public IntegrationFlow mqttOutboundFlow() {
133 | var broker = "tcp://localhost:1883";
134 | var clientID = "client-id";
135 | return f -> f.handle(new MqttPahoMessageHandler(broker, clientID));
136 | }
137 | ```
138 |
139 | More information regarding Spring MQTT integration can be found below
140 | on the [**Spring MQTT Support Homepage**](https://docs.spring.io/spring-integration/reference/html/mqtt.html#mqtt)
141 |
142 |
143 |
--------------------------------------------------------------------------------
/lib/utils/filters_test.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 | )
7 |
8 | func TestGetTopicInfo(t *testing.T) {
9 | type args struct {
10 | topicFilter string
11 | }
12 | tests := []struct {
13 | name string
14 | args args
15 | wantLevels []string
16 | wantIsShared bool
17 | wantShareName string
18 | wantErr bool
19 | }{
20 | // TODO: Add test cases.
21 | {
22 | "Single level wildcard",
23 | args{
24 | "sport/+/player1",
25 | },
26 | []string{"sport", "+", "player1"},
27 | false,
28 | "",
29 | false,
30 | },
31 | {
32 | "Single level wildcard ending with multi level",
33 | args{
34 | "+/tennis/#",
35 | },
36 | []string{"+", "tennis", "#"},
37 | false,
38 | "",
39 | false,
40 | },
41 | {
42 | "Shared subscription ending with multi level",
43 | args{
44 | "$share/consumer1/sports/tennis/#",
45 | },
46 | []string{"sports", "tennis", "#"},
47 | true,
48 | "consumer1",
49 | false,
50 | },
51 | {
52 | "Invalid shared subscription",
53 | args{
54 | "$share/consumer1",
55 | },
56 | nil,
57 | false,
58 | "",
59 | true,
60 | },
61 | }
62 | for _, tt := range tests {
63 | t.Run(tt.name, func(t *testing.T) {
64 | gotLevels, gotIsShared, gotShareName, err := GetTopicInfo(tt.args.topicFilter)
65 | if (err != nil) != tt.wantErr {
66 | t.Errorf("GetTopicInfo() error = %v, wantErr %v", err, tt.wantErr)
67 | return
68 | }
69 | if !reflect.DeepEqual(gotLevels, tt.wantLevels) {
70 | t.Errorf("GetTopicInfo() gotLevels = %v, want %v", gotLevels, tt.wantLevels)
71 | }
72 | if gotIsShared != tt.wantIsShared {
73 | t.Errorf("GetTopicInfo() gotIsShared = %v, want %v", gotIsShared, tt.wantIsShared)
74 | }
75 | if gotShareName != tt.wantShareName {
76 | t.Errorf("GetTopicInfo() gotShareName = %v, want %v", gotShareName, tt.wantShareName)
77 | }
78 | })
79 | }
80 | }
81 |
82 | func TestTopicMatches(t *testing.T) {
83 | type args struct {
84 | topic string
85 | topicFilter string
86 | }
87 | tests := []struct {
88 | name string
89 | args args
90 | wantMatches bool
91 | wantIsShared bool
92 | wantShareName string
93 | }{
94 | // TODO: Add test cases.
95 | {
96 | "Test 1",
97 | args{
98 | "sport/tennis/player1",
99 | "sport/tennis/player1/#",
100 | },
101 | true,
102 | false,
103 | "",
104 | },
105 | {
106 | "Test 2",
107 | args{
108 | "sport/tennis/player1/ranking",
109 | "sport/tennis/player1/#",
110 | },
111 | true,
112 | false,
113 | "",
114 | },
115 | {
116 | "Test 3",
117 | args{
118 | "sport/tennis/player1/score/wimbledon",
119 | "sport/tennis/player1/#",
120 | },
121 | true,
122 | false,
123 | "",
124 | },
125 | {
126 | "Test 4",
127 | args{
128 | "sport",
129 | "sport/#",
130 | },
131 | true,
132 | false,
133 | "",
134 | },
135 | {
136 | "Test 5",
137 | args{
138 | "sport/tennis/player1",
139 | "sport/+/player1",
140 | },
141 | true,
142 | false,
143 | "",
144 | },
145 | {
146 | "Test 6",
147 | args{
148 | "sport/tennis/player1",
149 | "$share/consumer/sport/+/player1",
150 | },
151 | true,
152 | true,
153 | "consumer",
154 | },
155 | {
156 | "Test 7",
157 | args{
158 | "sport/tennis/player1/tournaments/schedule",
159 | "$share/consumer/sport/+/+/#",
160 | },
161 | true,
162 | true,
163 | "consumer",
164 | },
165 | }
166 | for _, tt := range tests {
167 | t.Run(tt.name, func(t *testing.T) {
168 | gotMatches, gotIsShared, gotShareName := TopicMatches(tt.args.topic, tt.args.topicFilter)
169 | if gotMatches != tt.wantMatches {
170 | t.Errorf("TopicMatches() gotMatches = %v, want %v", gotMatches, tt.wantMatches)
171 | }
172 | if gotIsShared != tt.wantIsShared {
173 | t.Errorf("TopicMatches() gotIsShared = %v, want %v", gotIsShared, tt.wantIsShared)
174 | }
175 | if gotShareName != tt.wantShareName {
176 | t.Errorf("TopicMatches() gotShareName = %v, want %v", gotShareName, tt.wantShareName)
177 | }
178 | })
179 | }
180 | }
181 |
--------------------------------------------------------------------------------
/lib/persistence/badger_provider.go:
--------------------------------------------------------------------------------
1 | package persistence
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "github.com/c16a/hermes/lib/config"
7 | badger "github.com/dgraph-io/badger/v2"
8 | "github.com/dgraph-io/badger/v2/options"
9 | "github.com/eclipse/paho.golang/packets"
10 | uuid "github.com/satori/go.uuid"
11 | "go.uber.org/zap"
12 | "time"
13 | )
14 |
15 | const (
16 | PacketReserved byte = 1
17 | )
18 |
19 | type BadgerProvider struct {
20 | db *badger.DB
21 | }
22 |
23 | func NewBadgerProvider(config *config.Config, logger *zap.Logger) (Provider, error) {
24 | db, err := openDB(config)
25 | if err != nil {
26 | return nil, err
27 | }
28 | return &BadgerProvider{db: db}, nil
29 | }
30 |
31 | func openDB(config *config.Config) (*badger.DB, error) {
32 | offlineConfig := config.Server.Persistence.Badger
33 |
34 | var opts badger.Options
35 | if offlineConfig == nil {
36 | return nil, errors.New("offline configuration disabled")
37 | } else {
38 | if len(offlineConfig.Path) == 0 {
39 | opts = badger.DefaultOptions("").WithInMemory(true)
40 | } else {
41 | opts = badger.DefaultOptions(offlineConfig.Path)
42 | }
43 | opts.ValueLogLoadingMode = options.FileIO
44 | opts.NumMemtables = offlineConfig.NumTables
45 | opts.KeepL0InMemory = false
46 | opts.MaxTableSize = offlineConfig.MaxTableSize
47 | }
48 |
49 | return badger.Open(opts)
50 | }
51 |
52 | func (b *BadgerProvider) SaveForOfflineDelivery(clientId string, publish *packets.Publish) error {
53 | return b.db.Update(func(txn *badger.Txn) error {
54 | payloadBytes, err := getBytes(publish)
55 | if err != nil {
56 | return err
57 | }
58 | key := fmt.Sprintf("%s:%s", clientId, uuid.NewV4().String())
59 | var entry *badger.Entry
60 | if publish.Properties == nil || publish.Properties.MessageExpiry == nil {
61 | entry = badger.NewEntry([]byte(key), payloadBytes)
62 | } else {
63 | entry = badger.NewEntry([]byte(key), payloadBytes).WithTTL(time.Duration(int(*publish.Properties.MessageExpiry)) * time.Second)
64 | }
65 | return txn.SetEntry(entry)
66 | })
67 | }
68 |
69 | func (b *BadgerProvider) GetMissedMessages(clientID string) ([]*packets.Publish, error) {
70 | messages := make([]*packets.Publish, 0)
71 |
72 | var keysToFlush [][]byte
73 | err := b.db.View(func(txn *badger.Txn) error {
74 | it := txn.NewIterator(badger.DefaultIteratorOptions)
75 | defer it.Close()
76 | prefix := []byte(clientID)
77 | for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() {
78 | item := it.Item()
79 | if err := item.Value(func(val []byte) error {
80 | publish, err := getPublishPacket(val)
81 | if err != nil {
82 | return err
83 | }
84 | messages = append(messages, publish)
85 | keysToFlush = append(keysToFlush, item.Key())
86 | return nil
87 | }); err != nil {
88 | return err
89 | }
90 | }
91 | return nil
92 | })
93 |
94 | if err == nil {
95 | b.db.Update(func(txn *badger.Txn) error {
96 | for _, key := range keysToFlush {
97 | txn.Delete(key)
98 | }
99 | return nil
100 | })
101 | }
102 |
103 | return messages, err
104 | }
105 |
106 | func (b *BadgerProvider) ReservePacketID(clientID string, packetID uint16) error {
107 | return b.db.Update(func(txn *badger.Txn) error {
108 | key := fmt.Sprintf("packet:%s:%d", clientID, packetID)
109 | return txn.Set([]byte(key), []byte{PacketReserved})
110 | })
111 | }
112 |
113 | func (b *BadgerProvider) FreePacketID(clientID string, packetID uint16) error {
114 | return b.db.Update(func(txn *badger.Txn) error {
115 | key := fmt.Sprintf("packet:%s:%d", clientID, packetID)
116 | return txn.Delete([]byte(key))
117 | })
118 | }
119 |
120 | func (b *BadgerProvider) CheckForPacketIdReuse(clientID string, packetID uint16) (bool, error) {
121 | reuseFlag := false
122 | err := b.db.View(func(txn *badger.Txn) error {
123 | key := fmt.Sprintf("packet:%s:%d", clientID, packetID)
124 | item, err := txn.Get([]byte(key))
125 | if err != nil {
126 | return err
127 | }
128 | return item.Value(func(val []byte) error {
129 | if val[0] == PacketReserved {
130 | reuseFlag = true
131 | } else {
132 | return errors.New("some weird error")
133 | }
134 | return nil
135 | })
136 | })
137 | return reuseFlag, err
138 | }
139 |
--------------------------------------------------------------------------------
/.github/workflows/go.yml:
--------------------------------------------------------------------------------
1 | name: Merge Checks
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 | pull_request:
7 | branches: [ master ]
8 |
9 | jobs:
10 |
11 | test:
12 | name: Test
13 | runs-on: ubuntu-20.04
14 |
15 | steps:
16 | - name: Set up Go 1.x
17 | uses: actions/setup-go@v2
18 | with:
19 | go-version: ^1.17
20 |
21 | - name: Check out code into the Go module directory
22 | uses: actions/checkout@v2.3.4
23 |
24 | - name: Run tests
25 | run: go test -v ./...
26 |
27 | buildLinux:
28 | name: Build Linux
29 | runs-on: ubuntu-20.04
30 | needs:
31 | - test
32 |
33 | steps:
34 | - name: Set up Go 1.x
35 | uses: actions/setup-go@v2
36 | with:
37 | go-version: ^1.17
38 |
39 | - name: Check out code into the Go module directory
40 | uses: actions/checkout@v2.3.4
41 |
42 | - name: Get dependencies
43 | run: |
44 | go mod tidy
45 |
46 | - name: Build Linux x64
47 | run: GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o build/hermes_linux_amd64 github.com/c16a/hermes/app
48 |
49 | - name: Store Linux x64 artifacts
50 | uses: actions/upload-artifact@v2
51 | with:
52 | name: hermes_linux_amd64
53 | path: build/hermes_linux_amd64
54 |
55 | - name: Build Linux ARM 64-bit
56 | run: GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o build/hermes_linux_arm64 github.com/c16a/hermes/app
57 |
58 | - name: Store Linux ARM 64-bit artifacts
59 | uses: actions/upload-artifact@v2
60 | with:
61 | name: hermes_linux_arm64
62 | path: build/hermes_linux_arm64
63 |
64 | - name: Build Linux OpenPOWER 64-bit
65 | run: GOOS=linux GOARCH=ppc64le go build -ldflags="-s -w" -o build/hermes_linux_ppc64le github.com/c16a/hermes/app
66 |
67 | - name: Store Linux OpenPOWER 64-bit artifacts
68 | uses: actions/upload-artifact@v2
69 | with:
70 | name: hermes_linux_ppc64le
71 | path: build/hermes_linux_ppc64le
72 |
73 | buildWindows:
74 | name: Build Windows
75 | runs-on: ubuntu-20.04
76 | needs:
77 | - test
78 |
79 | steps:
80 | - name: Set up Go 1.x
81 | uses: actions/setup-go@v2
82 | with:
83 | go-version: ^1.17
84 |
85 | - name: Check out code into the Go module directory
86 | uses: actions/checkout@v2.3.4
87 |
88 | - name: Get dependencies
89 | run: |
90 | go mod tidy
91 |
92 | - name: Build Windows x64
93 | run: GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o build/hermes_windows_amd64.exe github.com/c16a/hermes/app
94 |
95 | - name: Store Windows x64 artifacts
96 | uses: actions/upload-artifact@v2
97 | with:
98 | name: hermes_windows_amd64.exe
99 | path: build/hermes_windows_amd64.exe
100 |
101 | buildMac:
102 | name: Build macOS
103 | runs-on: ubuntu-20.04
104 | needs:
105 | - test
106 |
107 | steps:
108 | - name: Set up Go 1.x
109 | uses: actions/setup-go@v2
110 | with:
111 | go-version: ^1.17
112 |
113 | - name: Check out code into the Go module directory
114 | uses: actions/checkout@v2.3.4
115 |
116 | - name: Get dependencies
117 | run: |
118 | go mod tidy
119 |
120 | - name: Build macOS Intel x64
121 | run: GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -o build/hermes_mac_amd64 github.com/c16a/hermes/app
122 |
123 | - name: Store macOS Intel x64 artifacts
124 | uses: actions/upload-artifact@v2
125 | with:
126 | name: hermes_mac_amd64
127 | path: build/hermes_mac_amd64
128 |
129 | - name: Build macOS Apple Silicon
130 | run: GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o build/hermes_mac_arm64 github.com/c16a/hermes/app
131 |
132 | - name: Store macOS Apple Silicon artifacts
133 | uses: actions/upload-artifact@v2
134 | with:
135 | name: hermes_mac_arm64
136 | path: build/hermes_mac_arm64
137 |
138 | docker:
139 | name: Build Image
140 | runs-on: ubuntu-20.04
141 | needs:
142 | - test
143 | - buildLinux
144 | - buildWindows
145 | - buildMac
146 |
147 | steps:
148 | - name: Check out code
149 | uses: actions/checkout@v2.3.4
150 |
151 | - name: Setup buildx
152 | uses: docker/setup-buildx-action@v1.3.0
153 |
154 | - name: Login to GitHub Container Registry
155 | uses: docker/login-action@v1.9.0
156 | with:
157 | registry: ghcr.io
158 | username: $GITHUB_ACTOR
159 | password: ${{ secrets.CR_PAT }}
160 |
161 | - name: Build and push
162 | uses: docker/build-push-action@v2.4.0
163 | with:
164 | context: .
165 | file: ./Dockerfile
166 | platforms: linux/amd64,linux/arm64
167 | username: $GITHUB_ACTOR
168 | password: ${{ secrets.CR_PAT }}
169 | push: false
170 | tags: |
171 | ghcr.io/c16a/hermes/hermes:latest
172 |
--------------------------------------------------------------------------------
/lib/mqtt/mqtt_handler.go:
--------------------------------------------------------------------------------
1 | package mqtt
2 |
3 | import (
4 | "errors"
5 | "github.com/eclipse/paho.golang/packets"
6 | "github.com/eclipse/paho.golang/paho"
7 | uuid "github.com/satori/go.uuid"
8 | "go.uber.org/zap"
9 | "io"
10 | )
11 |
12 | type MqttHandler struct {
13 | base MqttBase
14 | logger *zap.Logger
15 | }
16 |
17 | func (handler *MqttHandler) Handle(readWriter io.ReadWriter) {
18 | cPacket, err := packets.ReadPacket(readWriter)
19 | if err != nil {
20 | return
21 | }
22 |
23 | handler.logger.With(
24 | zap.Uint16("packetID", cPacket.PacketID()),
25 | zap.String("type", cPacket.PacketType()),
26 | ).Info("Received packet")
27 |
28 | var packetHandler func(io.ReadWriter, *packets.ControlPacket, MqttBase) error
29 |
30 | switch cPacket.Type {
31 | case packets.CONNECT:
32 | packetHandler = handleConnect
33 | break
34 | case packets.PUBLISH:
35 | packetHandler = handlePublish
36 | break
37 | case packets.PUBREL:
38 | packetHandler = handlePubRel
39 | case packets.SUBSCRIBE:
40 | packetHandler = handleSubscribe
41 | break
42 | case packets.UNSUBSCRIBE:
43 | packetHandler = handleUnsubscribe
44 | break
45 | case packets.DISCONNECT:
46 | packetHandler = handleDisconnect
47 | break
48 | case packets.PINGREQ:
49 | packetHandler = handlePingRequest
50 | default:
51 | return
52 | }
53 |
54 | err = packetHandler(readWriter, cPacket, handler.base)
55 | if err != nil {
56 | handler.logger.Error("error handling packet", zap.Error(err))
57 | }
58 |
59 | handler.logger.With(
60 | zap.Uint16("packetID", cPacket.PacketID()),
61 | zap.String("type", cPacket.PacketType()),
62 | ).Info("Writing packet")
63 |
64 | return
65 | }
66 |
67 | func handleConnect(readWriter io.ReadWriter, controlPacket *packets.ControlPacket, base MqttBase) error {
68 | connectPacket, ok := controlPacket.Content.(*packets.Connect)
69 | if !ok {
70 | return errors.New("invalid packet")
71 | }
72 |
73 | if len(connectPacket.ClientID) == 0 {
74 | connectPacket.ClientID = uuid.NewV4().String()
75 | }
76 |
77 | reasonCode, sessionPresent, maxQos := base.AddClient(readWriter, connectPacket)
78 |
79 | connAckPacket := packets.Connack{
80 | ReasonCode: reasonCode,
81 | SessionPresent: sessionPresent,
82 | Properties: &packets.Properties{
83 | AssignedClientID: connectPacket.ClientID,
84 | MaximumQOS: paho.Byte(maxQos),
85 | },
86 | }
87 |
88 | _, err := connAckPacket.WriteTo(readWriter)
89 | return err
90 | }
91 |
92 | func handleDisconnect(readWriter io.ReadWriter, controlPacket *packets.ControlPacket, base MqttBase) error {
93 | disconnectPacket, ok := controlPacket.Content.(*packets.Disconnect)
94 | if !ok {
95 | return errors.New("invalid packet")
96 | }
97 |
98 | base.Disconnect(readWriter, disconnectPacket)
99 | return nil
100 | }
101 |
102 | func handlePingRequest(readWriter io.ReadWriter, controlPacket *packets.ControlPacket, base MqttBase) error {
103 | _, ok := controlPacket.Content.(*packets.Pingreq)
104 | if !ok {
105 | return errors.New("invalid packet")
106 | }
107 |
108 | pingResponsePacket := packets.Pingresp{}
109 |
110 | _, err := pingResponsePacket.WriteTo(readWriter)
111 | return err
112 | }
113 |
114 | func handlePublish(readWriter io.ReadWriter, controlPacket *packets.ControlPacket, base MqttBase) error {
115 | publishPacket, ok := controlPacket.Content.(*packets.Publish)
116 | if !ok {
117 | return errors.New("invalid packet")
118 | }
119 |
120 | switch publishPacket.QoS {
121 | case 0:
122 | return handlePubQos0(publishPacket, base)
123 | case 1:
124 | return handlePubQoS1(readWriter, publishPacket, base)
125 | case 2:
126 | return handlePubQos2(readWriter, publishPacket, base)
127 | }
128 |
129 | return nil
130 | }
131 |
132 | func handlePubQos0(publishPacket *packets.Publish, base MqttBase) error {
133 | base.Publish(publishPacket)
134 | return nil
135 | }
136 |
137 | func handlePubQoS1(readWriter io.ReadWriter, publishPacket *packets.Publish, base MqttBase) error {
138 | pubAck := packets.Puback{
139 | ReasonCode: packets.PubackSuccess,
140 | PacketID: publishPacket.PacketID,
141 | }
142 |
143 | _, err := pubAck.WriteTo(readWriter)
144 | if err != nil {
145 | return err
146 | }
147 | base.Publish(publishPacket)
148 | return nil
149 | }
150 |
151 | func handlePubQos2(readWriter io.ReadWriter, publishPacket *packets.Publish, base MqttBase) error {
152 | pubReceived := packets.Pubrec{
153 | ReasonCode: packets.PubrecSuccess,
154 | PacketID: publishPacket.PacketID,
155 | }
156 |
157 | err := base.ReservePacketID(readWriter, publishPacket)
158 | if err != nil {
159 | pubReceived.ReasonCode = packets.PubrecImplementationSpecificError
160 | }
161 |
162 | _, err = pubReceived.WriteTo(readWriter)
163 | if err != nil {
164 | return err
165 | }
166 | base.Publish(publishPacket)
167 | return nil
168 | }
169 |
170 | func handlePubRel(readWriter io.ReadWriter, controlPacket *packets.ControlPacket, base MqttBase) error {
171 | pubRelPacket, ok := controlPacket.Content.(*packets.Pubrel)
172 | if !ok {
173 | return errors.New("invalid packet")
174 | }
175 |
176 | pubComplete := packets.Pubcomp{
177 | ReasonCode: packets.PubrecSuccess,
178 | PacketID: pubRelPacket.PacketID,
179 | }
180 |
181 | _ = base.FreePacketID(readWriter, pubRelPacket)
182 |
183 | _, err := pubComplete.WriteTo(readWriter)
184 | return err
185 | }
186 |
187 | func handleSubscribe(readWriter io.ReadWriter, controlPacket *packets.ControlPacket, base MqttBase) error {
188 | subscribePacket, ok := controlPacket.Content.(*packets.Subscribe)
189 | if !ok {
190 | return errors.New("invalid packet")
191 | }
192 |
193 | subAck := packets.Suback{
194 | PacketID: subscribePacket.PacketID,
195 | Reasons: base.Subscribe(readWriter, subscribePacket),
196 | }
197 |
198 | _, err := subAck.WriteTo(readWriter)
199 | return err
200 | }
201 |
202 | func handleUnsubscribe(readWriter io.ReadWriter, controlPacket *packets.ControlPacket, base MqttBase) error {
203 | unsubscribePacket, ok := controlPacket.Content.(*packets.Unsubscribe)
204 | if !ok {
205 | return errors.New("invalid packet")
206 | }
207 |
208 | unsubAck := packets.Unsuback{
209 | PacketID: unsubscribePacket.PacketID,
210 | Reasons: base.Unsubscribe(readWriter, unsubscribePacket),
211 | }
212 |
213 | _, err := unsubAck.WriteTo(readWriter)
214 | return err
215 | }
216 |
--------------------------------------------------------------------------------
/lib/mqtt/server_context.go:
--------------------------------------------------------------------------------
1 | package mqtt
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "github.com/c16a/hermes/lib/auth"
7 | "github.com/c16a/hermes/lib/config"
8 | "github.com/c16a/hermes/lib/persistence"
9 | "github.com/c16a/hermes/lib/utils"
10 | "github.com/eclipse/paho.golang/packets"
11 | "go.uber.org/zap"
12 | "io"
13 | "math/rand"
14 | "sync"
15 | "time"
16 | )
17 |
18 | // ServerContext stores the state of the cluster node
19 | type ServerContext struct {
20 | connectedClientsMap map[string]*ConnectedClient
21 | mu *sync.RWMutex
22 | config *config.Config
23 | authProvider auth.AuthorisationProvider
24 | persistenceProvider persistence.Provider
25 |
26 | logger *zap.Logger
27 | }
28 |
29 | // NewServerContext creates a new server context.
30 | //
31 | // This should only be called once per cluster node.
32 | func NewServerContext(c *config.Config, logger *zap.Logger) (*ServerContext, error) {
33 | authProvider, err := auth.FetchProviderFromConfig(c)
34 | if err != nil {
35 | logger.Error("auth provider setup failed", zap.Error(err))
36 | }
37 |
38 | var providerSetupFn func(*config.Config, *zap.Logger) (persistence.Provider, error)
39 | var persistenceProvider persistence.Provider
40 | switch c.Server.Persistence.Type {
41 | case "memory":
42 | providerSetupFn = persistence.NewBadgerProvider
43 | case "redis":
44 | providerSetupFn = persistence.NewRedisProvider
45 | }
46 |
47 | if providerSetupFn == nil {
48 | logger.Error("persistence provider cannot be chosen")
49 | } else {
50 | persistenceProvider, err = providerSetupFn(c, logger)
51 | if err != nil {
52 | logger.Error("persistence provider setup failed", zap.Error(err))
53 | }
54 | }
55 |
56 | return &ServerContext{
57 | mu: &sync.RWMutex{},
58 | connectedClientsMap: make(map[string]*ConnectedClient, 0),
59 | config: c,
60 | authProvider: authProvider,
61 | persistenceProvider: persistenceProvider,
62 | logger: logger,
63 | }, nil
64 | }
65 |
66 | func (ctx *ServerContext) AddClient(conn io.Writer, connect *packets.Connect) (code byte, sessionExists bool, maxQos byte) {
67 | maxQos = ctx.config.Server.MaxQos
68 |
69 | if ctx.authProvider != nil {
70 | if authError := ctx.authProvider.Validate(connect.Username, string(connect.Password)); authError != nil {
71 | code = 135
72 | sessionExists = false
73 | ctx.logger.Error("auth failed")
74 | return
75 | }
76 | ctx.logger.Info(fmt.Sprintf("auth succeeed for user: %s", connect.Username))
77 | }
78 |
79 | clientExists := ctx.checkForClient(connect.ClientID)
80 | clientRequestForFreshSession := connect.CleanStart
81 | if clientExists {
82 | if clientRequestForFreshSession {
83 | // If client asks for fresh session, delete existing ones
84 | ctx.logger.Info(fmt.Sprintf("Removing old connection for clientID: %s", connect.ClientID))
85 | delete(ctx.connectedClientsMap, connect.ClientID)
86 | ctx.doAddClient(conn, connect)
87 | } else {
88 | ctx.logger.Info(fmt.Sprintf("Updating clientID: %s with new connection", connect.ClientID))
89 | ctx.doUpdateClient(connect.ClientID, conn)
90 | if ctx.persistenceProvider != nil {
91 | ctx.logger.Info(fmt.Sprintf("Fetching missed messages for clientID: %s", connect.ClientID))
92 | err := ctx.sendMissedMessages(connect.ClientID, conn)
93 | if err != nil {
94 | ctx.logger.Error("failed to fetch offline messages", zap.Error(err))
95 | }
96 | }
97 | }
98 | } else {
99 | ctx.doAddClient(conn, connect)
100 | }
101 | code = 0
102 | sessionExists = clientExists && !clientRequestForFreshSession
103 | return
104 | }
105 |
106 | func (ctx *ServerContext) Disconnect(conn io.Writer, disconnect *packets.Disconnect) {
107 | var clientIdToRemove string
108 | shouldDelete := false
109 | for clientID, client := range ctx.connectedClientsMap {
110 | if client.Connection == conn {
111 | clientIdToRemove = clientID
112 | if client.IsClean {
113 | shouldDelete = true
114 | }
115 | }
116 | }
117 |
118 | if shouldDelete {
119 | ctx.logger.Info(fmt.Sprintf("Deleting connection for clientID: %s", clientIdToRemove))
120 | delete(ctx.connectedClientsMap, clientIdToRemove)
121 | } else {
122 | ctx.logger.Info(fmt.Sprintf("Marking connection as disconnected for clientID: %s", clientIdToRemove))
123 | ctx.mu.Lock()
124 | ctx.connectedClientsMap[clientIdToRemove].IsConnected = false
125 | ctx.mu.Unlock()
126 | }
127 | }
128 |
129 | // Publish publishes a message to a topic
130 | func (ctx *ServerContext) Publish(publish *packets.Publish) {
131 | var shareNameClientMap = make(map[string][]*ConnectedClient, 0)
132 | for _, client := range ctx.connectedClientsMap {
133 | topicToTarget := publish.Topic
134 | for topicFilter, _ := range client.Subscriptions {
135 | matches, isShared, shareName := utils.TopicMatches(topicToTarget, topicFilter)
136 | if matches {
137 | if !isShared {
138 | // non-shared subscriptions
139 | if !client.IsConnected && !client.IsClean && ctx.persistenceProvider != nil {
140 | // save for offline usage
141 | ctx.logger.Info(fmt.Sprintf("Saving offline delivery message for clientID: %s", client.ClientID))
142 | err := ctx.persistenceProvider.SaveForOfflineDelivery(client.ClientID, publish)
143 | if err != nil {
144 | ctx.logger.Error("failed to save offline message", zap.Error(err))
145 | }
146 | }
147 | if client.IsConnected {
148 | // send direct message
149 | publish.WriteTo(client.Connection)
150 | }
151 | } else {
152 | // share subscriptions
153 | if len(shareNameClientMap[shareName]) == 0 {
154 | shareNameClientMap[shareName] = make([]*ConnectedClient, 0)
155 | }
156 | shareNameClientMap[shareName] = append(shareNameClientMap[shareName], client)
157 | }
158 | }
159 | }
160 | }
161 |
162 | for _, clients := range shareNameClientMap {
163 | onlineClients := make([]*ConnectedClient, 0)
164 | for _, c := range clients {
165 | if c.IsConnected {
166 | onlineClients = append(onlineClients, c)
167 | }
168 | }
169 |
170 | var client *ConnectedClient
171 | if len(clients) == 1 {
172 | client = clients[0]
173 | } else {
174 | rand.Seed(time.Now().Unix())
175 | s := rand.NewSource(time.Now().Unix())
176 | r := rand.New(s) // initialize local pseudorandom generator
177 | luckyClientIndex := r.Intn(len(clients))
178 | client = clients[luckyClientIndex]
179 | }
180 | publish.WriteTo(client.Connection)
181 | }
182 | }
183 |
184 | func (ctx *ServerContext) Subscribe(conn io.Writer, subscribe *packets.Subscribe) []byte {
185 | ctx.mu.Lock()
186 | defer ctx.mu.Unlock()
187 |
188 | var subAckBytes []byte
189 | for _, client := range ctx.connectedClientsMap {
190 | if conn == client.Connection {
191 | for topic, options := range subscribe.Subscriptions {
192 | client.Subscriptions[topic] = options
193 | var subAckByte byte
194 |
195 | if options.QoS > ctx.config.Server.MaxQos {
196 | subAckByte = packets.SubackImplementationspecificerror
197 | } else {
198 | switch options.QoS {
199 | case 0:
200 | subAckByte = packets.SubackGrantedQoS0
201 | break
202 | case 1:
203 | subAckByte = packets.SubackGrantedQoS1
204 | break
205 | case 2:
206 | subAckByte = packets.SubackGrantedQoS2
207 | break
208 | default:
209 | subAckByte = packets.SubackUnspecifiederror
210 | }
211 | }
212 | subAckBytes = append(subAckBytes, subAckByte)
213 | }
214 | }
215 | }
216 | return subAckBytes
217 | }
218 |
219 | func (ctx *ServerContext) Unsubscribe(conn io.Writer, unsubscribe *packets.Unsubscribe) []byte {
220 | client, _ := ctx.getClientForConnection(conn)
221 |
222 | var unsubAckBytes []byte
223 | for _, topic := range unsubscribe.Topics {
224 | _, ok := client.Subscriptions[topic]
225 | if ok {
226 | delete(client.Subscriptions, topic)
227 | unsubAckBytes = append(unsubAckBytes, packets.UnsubackSuccess)
228 | } else {
229 | unsubAckBytes = append(unsubAckBytes, packets.UnsubackNoSubscriptionFound)
230 | }
231 | }
232 | return unsubAckBytes
233 | }
234 |
235 | func (ctx *ServerContext) ReservePacketID(conn io.Writer, publish *packets.Publish) error {
236 | client, err := ctx.getClientForConnection(conn)
237 | if err != nil {
238 | return err
239 | }
240 | return ctx.persistenceProvider.ReservePacketID(client.ClientID, publish.PacketID)
241 | }
242 |
243 | func (ctx *ServerContext) FreePacketID(conn io.Writer, pubRel *packets.Pubrel) error {
244 | client, err := ctx.getClientForConnection(conn)
245 | if err != nil {
246 | return err
247 | }
248 | return ctx.persistenceProvider.FreePacketID(client.ClientID, pubRel.PacketID)
249 | }
250 |
251 | func (ctx *ServerContext) checkForClient(clientID string) bool {
252 | ctx.mu.RLock()
253 | defer ctx.mu.RUnlock()
254 |
255 | for oldClientID := range ctx.connectedClientsMap {
256 | if clientID == oldClientID {
257 | return true
258 | }
259 | }
260 | return false
261 | }
262 |
263 | func (ctx *ServerContext) getClientForConnection(conn io.Writer) (*ConnectedClient, error) {
264 | ctx.mu.RLock()
265 | defer ctx.mu.RUnlock()
266 |
267 | for _, client := range ctx.connectedClientsMap {
268 | if conn == client.Connection {
269 | return client, nil
270 | }
271 | }
272 | return nil, errors.New("client not found for connection")
273 | }
274 |
275 | func (ctx *ServerContext) sendMissedMessages(clientId string, conn io.Writer) error {
276 | missedMessages, err := ctx.persistenceProvider.GetMissedMessages(clientId)
277 | if err != nil {
278 | return err
279 | }
280 |
281 | for _, msg := range missedMessages {
282 | if _, writeErr := msg.WriteTo(conn); writeErr != nil {
283 | if ctx.persistenceProvider.SaveForOfflineDelivery(clientId, msg) != nil {
284 | ctx.logger.Error("failed to save offline message", zap.Error(err))
285 | }
286 | }
287 | }
288 | return nil
289 | }
290 |
291 | func (ctx *ServerContext) doAddClient(conn io.Writer, connect *packets.Connect) {
292 | newClient := &ConnectedClient{
293 | Connection: conn,
294 | ClientID: connect.ClientID,
295 | IsClean: connect.CleanStart,
296 | IsConnected: true,
297 | Subscriptions: make(map[string]packets.SubOptions, 0),
298 | }
299 |
300 | ctx.logger.Info(fmt.Sprintf("Creating new connection for clientID: %s", connect.ClientID))
301 | ctx.mu.Lock()
302 | ctx.connectedClientsMap[connect.ClientID] = newClient
303 | ctx.mu.Unlock()
304 | }
305 |
306 | func (ctx *ServerContext) doUpdateClient(clientID string, conn io.Writer) {
307 | ctx.connectedClientsMap[clientID].Connection = conn
308 | }
309 |
310 | // ConnectedClient stores the information about a currently connected client
311 | type ConnectedClient struct {
312 | Connection io.Writer
313 | ClientID string
314 | ClientGroup string
315 | IsConnected bool
316 | IsClean bool
317 | Subscriptions map[string]packets.SubOptions
318 | }
319 |
--------------------------------------------------------------------------------
/lib/mqtt/server_context_test.go:
--------------------------------------------------------------------------------
1 | package mqtt
2 |
3 | import (
4 | "errors"
5 | "github.com/c16a/hermes/lib/auth"
6 | "github.com/c16a/hermes/lib/config"
7 | "github.com/c16a/hermes/lib/persistence"
8 | "github.com/eclipse/paho.golang/packets"
9 | "go.uber.org/zap"
10 | "io"
11 | "io/ioutil"
12 | "reflect"
13 | "sync"
14 | "testing"
15 | )
16 |
17 | func TestNewServerContext(t *testing.T) {
18 | type args struct {
19 | config *config.Config
20 | }
21 | tests := []struct {
22 | name string
23 | args args
24 | want *ServerContext
25 | wantErr bool
26 | }{
27 | // TODO: Add test cases.
28 | }
29 | logger := zap.NewNop()
30 | for _, tt := range tests {
31 | t.Run(tt.name, func(t *testing.T) {
32 | got, err := NewServerContext(tt.args.config, logger)
33 | if (err != nil) != tt.wantErr {
34 | t.Errorf("NewServerContext() error = %v, wantErr %v", err, tt.wantErr)
35 | return
36 | }
37 | if !reflect.DeepEqual(got, tt.want) {
38 | t.Errorf("NewServerContext() got = %v, want %v", got, tt.want)
39 | }
40 | })
41 | }
42 | }
43 |
44 | func TestServerContext_AddClient(t *testing.T) {
45 | type fields struct {
46 | connectedClientsMap map[string]*ConnectedClient
47 | mu *sync.RWMutex
48 | config *config.Config
49 | authProvider auth.AuthorisationProvider
50 | persistenceProvider persistence.Provider
51 | }
52 | type args struct {
53 | conn io.Writer
54 | connect *packets.Connect
55 | }
56 | tests := []struct {
57 | name string
58 | fields fields
59 | args args
60 | wantCode byte
61 | wantSessionExists bool
62 | wantMaxQos byte
63 | }{
64 | {
65 | "Auth failed",
66 | fields{
67 | make(map[string]*ConnectedClient, 0),
68 | &sync.RWMutex{},
69 | &config.Config{
70 | Server: &config.Server{
71 | MaxQos: 2,
72 | },
73 | },
74 | &MockAuthProvider{throwError: true},
75 | nil,
76 | },
77 | args{
78 | ioutil.Discard,
79 | &packets.Connect{
80 | CleanStart: true,
81 | ClientID: "abcd",
82 | },
83 | },
84 | 135,
85 | false,
86 | 2,
87 | },
88 | {
89 | "Adding fresh client",
90 | fields{
91 | make(map[string]*ConnectedClient, 0),
92 | &sync.RWMutex{},
93 | &config.Config{
94 | Server: &config.Server{
95 | MaxQos: 2,
96 | },
97 | },
98 | nil,
99 | nil,
100 | },
101 | args{
102 | ioutil.Discard,
103 | &packets.Connect{
104 | CleanStart: true,
105 | ClientID: "abcd",
106 | },
107 | },
108 | 0,
109 | false,
110 | 2,
111 | },
112 | {
113 | "Existing client asked to revive session (no persistence)",
114 | fields{
115 | map[string]*ConnectedClient{
116 | "abcd": {
117 | ClientID: "abcd",
118 | Connection: ioutil.Discard,
119 | },
120 | },
121 | &sync.RWMutex{},
122 | &config.Config{
123 | Server: &config.Server{
124 | MaxQos: 2,
125 | },
126 | },
127 | nil,
128 | nil,
129 | },
130 | args{
131 | ioutil.Discard,
132 | &packets.Connect{
133 | CleanStart: false,
134 | ClientID: "abcd",
135 | },
136 | },
137 | 0,
138 | true,
139 | 2,
140 | },
141 | {
142 | "Existing client asked to revive session (with persistence)",
143 | fields{
144 | map[string]*ConnectedClient{
145 | "abcd": {
146 | ClientID: "abcd",
147 | Connection: ioutil.Discard,
148 | },
149 | },
150 | &sync.RWMutex{},
151 | &config.Config{
152 | Server: &config.Server{
153 | MaxQos: 2,
154 | },
155 | },
156 | nil,
157 | &MockPersistenceProvider{},
158 | },
159 | args{
160 | ioutil.Discard,
161 | &packets.Connect{
162 | CleanStart: false,
163 | ClientID: "abcd",
164 | },
165 | },
166 | 0,
167 | true,
168 | 2,
169 | },
170 | {
171 | "Existing client asked for fresh session",
172 | fields{
173 | map[string]*ConnectedClient{
174 | "abcd": {
175 | ClientID: "abcd",
176 | Connection: ioutil.Discard,
177 | },
178 | },
179 | &sync.RWMutex{},
180 | &config.Config{
181 | Server: &config.Server{
182 | MaxQos: 2,
183 | },
184 | },
185 | nil,
186 | nil,
187 | },
188 | args{
189 | ioutil.Discard,
190 | &packets.Connect{
191 | CleanStart: true,
192 | ClientID: "abcd",
193 | },
194 | },
195 | 0,
196 | false,
197 | 2,
198 | },
199 | }
200 | for _, tt := range tests {
201 | t.Run(tt.name, func(t *testing.T) {
202 | ctx := &ServerContext{
203 | connectedClientsMap: tt.fields.connectedClientsMap,
204 | mu: tt.fields.mu,
205 | config: tt.fields.config,
206 | authProvider: tt.fields.authProvider,
207 | persistenceProvider: tt.fields.persistenceProvider,
208 | logger: zap.NewNop(),
209 | }
210 | gotCode, gotSessionExists, gotMaxQos := ctx.AddClient(tt.args.conn, tt.args.connect)
211 | if gotCode != tt.wantCode {
212 | t.Errorf("AddClient() gotCode = %v, want %v", gotCode, tt.wantCode)
213 | }
214 | if gotSessionExists != tt.wantSessionExists {
215 | t.Errorf("AddClient() gotSessionExists = %v, want %v", gotSessionExists, tt.wantSessionExists)
216 | }
217 | if gotMaxQos != tt.wantMaxQos {
218 | t.Errorf("AddClient() gotSessionExists = %v, want %v", gotMaxQos, tt.wantMaxQos)
219 | }
220 | })
221 | }
222 | }
223 |
224 | func TestServerContext_Disconnect(t *testing.T) {
225 | type fields struct {
226 | connectedClientsMap map[string]*ConnectedClient
227 | mu *sync.RWMutex
228 | config *config.Config
229 | authProvider auth.AuthorisationProvider
230 | persistenceProvider persistence.Provider
231 | }
232 | type args struct {
233 | conn io.Writer
234 | disconnect *packets.Disconnect
235 | }
236 | tests := []struct {
237 | name string
238 | fields fields
239 | args args
240 | }{
241 | {
242 | "Deleting clean client",
243 | fields{
244 | map[string]*ConnectedClient{
245 | "abcd": {
246 | ClientID: "abcd",
247 | Connection: ioutil.Discard,
248 | IsClean: true,
249 | },
250 | },
251 | &sync.RWMutex{},
252 | &config.Config{},
253 | nil,
254 | nil,
255 | },
256 | args{
257 | ioutil.Discard,
258 | &packets.Disconnect{},
259 | },
260 | },
261 | {
262 | "Deleting persisted client",
263 | fields{
264 | map[string]*ConnectedClient{
265 | "abcd": {
266 | ClientID: "abcd",
267 | Connection: ioutil.Discard,
268 | IsClean: false,
269 | },
270 | },
271 | &sync.RWMutex{},
272 | &config.Config{},
273 | nil,
274 | nil,
275 | },
276 | args{
277 | ioutil.Discard,
278 | &packets.Disconnect{},
279 | },
280 | },
281 | }
282 | for _, tt := range tests {
283 | t.Run(tt.name, func(t *testing.T) {
284 | ctx := &ServerContext{
285 | connectedClientsMap: tt.fields.connectedClientsMap,
286 | mu: tt.fields.mu,
287 | config: tt.fields.config,
288 | authProvider: tt.fields.authProvider,
289 | persistenceProvider: tt.fields.persistenceProvider,
290 | logger: zap.NewNop(),
291 | }
292 | ctx.Disconnect(tt.args.conn, tt.args.disconnect)
293 | })
294 | }
295 | }
296 |
297 | func TestServerContext_Publish(t *testing.T) {
298 | type fields struct {
299 | connectedClientsMap map[string]*ConnectedClient
300 | mu *sync.RWMutex
301 | config *config.Config
302 | authProvider auth.AuthorisationProvider
303 | persistenceProvider persistence.Provider
304 | }
305 | type args struct {
306 | publish *packets.Publish
307 | }
308 | tests := []struct {
309 | name string
310 | fields fields
311 | args args
312 | }{
313 | {
314 | name: "Publish to connected client",
315 | fields: fields{
316 | map[string]*ConnectedClient{
317 | "abcd": {
318 | ClientID: "abcd",
319 | Connection: ioutil.Discard,
320 | IsConnected: true,
321 | Subscriptions: map[string]packets.SubOptions{
322 | "foo": {},
323 | },
324 | },
325 | },
326 | &sync.RWMutex{},
327 | &config.Config{},
328 | nil,
329 | nil,
330 | },
331 | args: args{
332 | &packets.Publish{
333 | Topic: "foo",
334 | Payload: []byte("Hello World"),
335 | },
336 | },
337 | },
338 | {
339 | name: "Publish to disconnected persistent client (no persistence)",
340 | fields: fields{
341 | map[string]*ConnectedClient{
342 | "abcd": {
343 | ClientID: "abcd",
344 | Connection: ioutil.Discard,
345 | IsConnected: false,
346 | IsClean: false,
347 | Subscriptions: map[string]packets.SubOptions{
348 | "foo": {},
349 | },
350 | },
351 | },
352 | &sync.RWMutex{},
353 | &config.Config{},
354 | nil,
355 | nil,
356 | },
357 | args: args{
358 | &packets.Publish{
359 | Topic: "foo",
360 | Payload: []byte("Hello World"),
361 | },
362 | },
363 | },
364 | {
365 | name: "Publish to disconnected persistent client (with persistence)",
366 | fields: fields{
367 | map[string]*ConnectedClient{
368 | "abcd": {
369 | ClientID: "abcd",
370 | Connection: ioutil.Discard,
371 | IsConnected: false,
372 | IsClean: false,
373 | Subscriptions: map[string]packets.SubOptions{
374 | "foo": {},
375 | },
376 | },
377 | },
378 | &sync.RWMutex{},
379 | &config.Config{},
380 | nil,
381 | &MockPersistenceProvider{},
382 | },
383 | args: args{
384 | &packets.Publish{
385 | Topic: "foo",
386 | Payload: []byte("Hello World"),
387 | },
388 | },
389 | },
390 | }
391 | for _, tt := range tests {
392 | t.Run(tt.name, func(t *testing.T) {
393 | ctx := &ServerContext{
394 | connectedClientsMap: tt.fields.connectedClientsMap,
395 | mu: tt.fields.mu,
396 | config: tt.fields.config,
397 | authProvider: tt.fields.authProvider,
398 | persistenceProvider: tt.fields.persistenceProvider,
399 | logger: zap.NewNop(),
400 | }
401 | ctx.Publish(tt.args.publish)
402 | })
403 | }
404 | }
405 |
406 | func TestServerContext_Subscribe(t *testing.T) {
407 | type fields struct {
408 | connectedClientsMap map[string]*ConnectedClient
409 | mu *sync.RWMutex
410 | config *config.Config
411 | authProvider auth.AuthorisationProvider
412 | persistenceProvider persistence.Provider
413 | }
414 | type args struct {
415 | conn io.Writer
416 | subscribe *packets.Subscribe
417 | }
418 | tests := []struct {
419 | name string
420 | fields fields
421 | args args
422 | want []byte
423 | }{
424 | {
425 | "Subscribing QoS 0",
426 | fields{
427 | map[string]*ConnectedClient{
428 | "abcd": {
429 | ClientID: "abcd",
430 | Connection: ioutil.Discard,
431 | IsConnected: false,
432 | IsClean: false,
433 | Subscriptions: make(map[string]packets.SubOptions, 0),
434 | },
435 | },
436 | &sync.RWMutex{},
437 | &config.Config{
438 | Server: &config.Server{
439 | MaxQos: 2,
440 | },
441 | },
442 | nil,
443 | &MockPersistenceProvider{},
444 | },
445 | args{
446 | ioutil.Discard,
447 | &packets.Subscribe{
448 | Subscriptions: map[string]packets.SubOptions{
449 | "foo": {
450 | QoS: 0,
451 | },
452 | },
453 | },
454 | },
455 | []byte{packets.SubackGrantedQoS0},
456 | },
457 | {
458 | "Subscribing QoS 1",
459 | fields{
460 | map[string]*ConnectedClient{
461 | "abcd": {
462 | ClientID: "abcd",
463 | Connection: ioutil.Discard,
464 | IsConnected: false,
465 | IsClean: false,
466 | Subscriptions: make(map[string]packets.SubOptions, 0),
467 | },
468 | },
469 | &sync.RWMutex{},
470 | &config.Config{
471 | Server: &config.Server{
472 | MaxQos: 2,
473 | },
474 | },
475 | nil,
476 | &MockPersistenceProvider{},
477 | },
478 | args{
479 | ioutil.Discard,
480 | &packets.Subscribe{
481 | Subscriptions: map[string]packets.SubOptions{
482 | "foo": {
483 | QoS: 1,
484 | },
485 | },
486 | },
487 | },
488 | []byte{packets.SubackGrantedQoS1},
489 | },
490 | {
491 | "Subscribing QoS 2",
492 | fields{
493 | map[string]*ConnectedClient{
494 | "abcd": {
495 | ClientID: "abcd",
496 | Connection: ioutil.Discard,
497 | IsConnected: false,
498 | IsClean: false,
499 | Subscriptions: make(map[string]packets.SubOptions, 0),
500 | },
501 | },
502 | &sync.RWMutex{},
503 | &config.Config{
504 | Server: &config.Server{
505 | MaxQos: 2,
506 | },
507 | },
508 | nil,
509 | &MockPersistenceProvider{},
510 | },
511 | args{
512 | ioutil.Discard,
513 | &packets.Subscribe{
514 | Subscriptions: map[string]packets.SubOptions{
515 | "foo": {
516 | QoS: 2,
517 | },
518 | },
519 | },
520 | },
521 | []byte{packets.SubackGrantedQoS2},
522 | },
523 | {
524 | "Subscribing to higher Qos",
525 | fields{
526 | map[string]*ConnectedClient{
527 | "abcd": {
528 | ClientID: "abcd",
529 | Connection: ioutil.Discard,
530 | IsConnected: false,
531 | IsClean: false,
532 | Subscriptions: make(map[string]packets.SubOptions, 0),
533 | },
534 | },
535 | &sync.RWMutex{},
536 | &config.Config{
537 | Server: &config.Server{
538 | MaxQos: 1,
539 | },
540 | },
541 | nil,
542 | &MockPersistenceProvider{},
543 | },
544 | args{
545 | ioutil.Discard,
546 | &packets.Subscribe{
547 | Subscriptions: map[string]packets.SubOptions{
548 | "foo": {
549 | QoS: 2,
550 | },
551 | },
552 | },
553 | },
554 | []byte{packets.SubackImplementationspecificerror},
555 | },
556 | }
557 | for _, tt := range tests {
558 | t.Run(tt.name, func(t *testing.T) {
559 | ctx := &ServerContext{
560 | connectedClientsMap: tt.fields.connectedClientsMap,
561 | mu: tt.fields.mu,
562 | config: tt.fields.config,
563 | authProvider: tt.fields.authProvider,
564 | persistenceProvider: tt.fields.persistenceProvider,
565 | logger: zap.NewNop(),
566 | }
567 | if got := ctx.Subscribe(tt.args.conn, tt.args.subscribe); !reflect.DeepEqual(got, tt.want) {
568 | t.Errorf("Subscribe() = %v, want %v", got, tt.want)
569 | }
570 | })
571 | }
572 | }
573 |
574 | func TestServerContext_Unsubscribe(t *testing.T) {
575 | type fields struct {
576 | connectedClientsMap map[string]*ConnectedClient
577 | mu *sync.RWMutex
578 | config *config.Config
579 | authProvider auth.AuthorisationProvider
580 | persistenceProvider persistence.Provider
581 | }
582 | type args struct {
583 | conn io.Writer
584 | unsubscribe *packets.Unsubscribe
585 | }
586 | tests := []struct {
587 | name string
588 | fields fields
589 | args args
590 | want []byte
591 | }{
592 | {
593 | "Unsubscribe known topic",
594 | fields{
595 | map[string]*ConnectedClient{
596 | "abcd": {
597 | ClientID: "abcd",
598 | Connection: ioutil.Discard,
599 | IsConnected: false,
600 | IsClean: false,
601 | Subscriptions: map[string]packets.SubOptions{
602 | "foo": {
603 | QoS: 1,
604 | },
605 | },
606 | },
607 | },
608 | &sync.RWMutex{},
609 | &config.Config{
610 | Server: &config.Server{
611 | MaxQos: 1,
612 | },
613 | },
614 | nil,
615 | &MockPersistenceProvider{},
616 | },
617 | args{
618 | conn: ioutil.Discard,
619 | unsubscribe: &packets.Unsubscribe{
620 | Topics: []string{"foo", "bar"},
621 | Properties: nil,
622 | PacketID: 0,
623 | },
624 | },
625 | []byte{packets.UnsubackSuccess, packets.UnsubackNoSubscriptionFound},
626 | },
627 | }
628 | for _, tt := range tests {
629 | t.Run(tt.name, func(t *testing.T) {
630 | ctx := &ServerContext{
631 | connectedClientsMap: tt.fields.connectedClientsMap,
632 | mu: tt.fields.mu,
633 | config: tt.fields.config,
634 | authProvider: tt.fields.authProvider,
635 | persistenceProvider: tt.fields.persistenceProvider,
636 | logger: zap.NewNop(),
637 | }
638 | if got := ctx.Unsubscribe(tt.args.conn, tt.args.unsubscribe); !reflect.DeepEqual(got, tt.want) {
639 | t.Errorf("Unsubscribe() = %v, want %v", got, tt.want)
640 | }
641 | })
642 | }
643 | }
644 |
645 | type MockPersistenceProvider struct {
646 | }
647 |
648 | func (m *MockPersistenceProvider) ReservePacketID(clientID string, packetID uint16) error {
649 | return nil
650 | }
651 |
652 | func (m *MockPersistenceProvider) FreePacketID(clientID string, packetID uint16) error {
653 | return nil
654 | }
655 |
656 | func (m *MockPersistenceProvider) CheckForPacketIdReuse(clientID string, packetID uint16) (bool, error) {
657 | return false, nil
658 | }
659 |
660 | func (m *MockPersistenceProvider) SaveForOfflineDelivery(clientId string, publish *packets.Publish) error {
661 | return nil
662 | }
663 |
664 | func (m *MockPersistenceProvider) GetMissedMessages(clientId string) ([]*packets.Publish, error) {
665 | return []*packets.Publish{
666 | {
667 | Topic: "foo",
668 | Payload: []byte("Hello World"),
669 | },
670 | }, nil
671 | }
672 |
673 | type MockAuthProvider struct {
674 | throwError bool
675 | }
676 |
677 | func (m *MockAuthProvider) Validate(username string, password string) error {
678 | if m.throwError {
679 | return errors.New("some random error")
680 | }
681 | return nil
682 | }
683 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c h1:/IBSNwUN8+eKzUzbJPqhK839ygXJ82sde8x3ogr6R28=
2 | github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
3 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
4 | github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
5 | github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
6 | github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
7 | github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
8 | github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
9 | github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
10 | github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
11 | github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
12 | github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
13 | github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
14 | github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
15 | github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
16 | github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
17 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
18 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
19 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
20 | github.com/dgraph-io/badger/v2 v2.2007.4 h1:TRWBQg8UrlUhaFdco01nO2uXwzKS7zd+HVdwV/GHc4o=
21 | github.com/dgraph-io/badger/v2 v2.2007.4/go.mod h1:vSw/ax2qojzbN6eXHIx6KPKtCSHJN/Uz0X0VPruTIhk=
22 | github.com/dgraph-io/ristretto v0.0.3-0.20200630154024-f66de99634de h1:t0UHb5vdojIDUqktM6+xJAfScFBsVpXZmqC9dsgJmeA=
23 | github.com/dgraph-io/ristretto v0.0.3-0.20200630154024-f66de99634de/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E=
24 | github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
25 | github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
26 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
27 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
28 | github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
29 | github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
30 | github.com/eclipse/paho.golang v0.10.0 h1:oUGPjRwWcZQRgDD9wVDV7y7i7yBSxts3vcvcNJo8B4Q=
31 | github.com/eclipse/paho.golang v0.10.0/go.mod h1:rhrV37IEwauUyx8FHrvmXOKo+QRKng5ncoN1vJiJMcs=
32 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
33 | github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
34 | github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
35 | github.com/go-asn1-ber/asn1-ber v1.5.1 h1:pDbRAunXzIUXfx4CB2QJFv5IuPiuoW+sWvr/Us009o8=
36 | github.com/go-asn1-ber/asn1-ber v1.5.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
37 | github.com/go-ldap/ldap/v3 v3.4.1 h1:fU/0xli6HY02ocbMuozHAYsaHLcnkLjvho2r5a34BUU=
38 | github.com/go-ldap/ldap/v3 v3.4.1/go.mod h1:iYS1MdmrmceOJ1QOTnRXrIs7i3kloqtmGQjRvjKpyMg=
39 | github.com/go-redis/redis/v8 v8.11.3 h1:GCjoYp8c+yQTJfc0n69iwSiHjvuAdruxl7elnZCxgt8=
40 | github.com/go-redis/redis/v8 v8.11.3/go.mod h1:xNJ9xDG09FsIPwh3bWdk+0oDWHbtF9rPN0F/oD9XeKc=
41 | github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
42 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
43 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
44 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
45 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
46 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
47 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
48 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
49 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
50 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
51 | github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
52 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
53 | github.com/golang/snappy v0.0.3 h1:fHPg5GQYlCeLIPB9BZqMVR5nR9A+IM5zcgeTdjMYmLA=
54 | github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
55 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
56 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
57 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
58 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
59 | github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
60 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
61 | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
62 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
63 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
64 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
65 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
66 | github.com/klauspost/compress v1.12.3 h1:G5AfA94pHPysR56qqrkO2pxEexdDzrpFJ6yt/VqWxVU=
67 | github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
68 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
69 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
70 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
71 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
72 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
73 | github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
74 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
75 | github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
76 | github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
77 | github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
78 | github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
79 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
80 | github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
81 | github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
82 | github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
83 | github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
84 | github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
85 | github.com/onsi/gomega v1.15.0 h1:WjP/FQ/sk43MRmnEcT+MlDw2TFvkrXlprrPST/IudjU=
86 | github.com/onsi/gomega v1.15.0/go.mod h1:cIuvLEne0aoVhAgh/O6ac0Op8WWw9H6eYCriF+tEHG0=
87 | github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
88 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
89 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
90 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
91 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
92 | github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
93 | github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
94 | github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
95 | github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
96 | github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
97 | github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
98 | github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
99 | github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
100 | github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
101 | github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
102 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
103 | github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
104 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
105 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
106 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
107 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
108 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
109 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
110 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
111 | github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
112 | github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
113 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
114 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
115 | go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
116 | go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
117 | go.uber.org/goleak v1.1.11-0.20210813005559-691160354723 h1:sHOAIxRGBp443oHZIPB+HsUGaksVCXVQENPxwTfQdH4=
118 | go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
119 | go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
120 | go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
121 | go.uber.org/zap v1.19.1 h1:ue41HOKd1vGURxrmeKIgELGb3jPW9DMUDGtsinblHwI=
122 | go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
123 | golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
124 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
125 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
126 | golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
127 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
128 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
129 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
130 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
131 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
132 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
133 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
134 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
135 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
136 | golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
137 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
138 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
139 | golang.org/x/net v0.0.0-20210428140749-89ef3d95e781 h1:DzZ89McO9/gWPsQXS/FVKAlG02ZjaQ6AlZRBimEYOd0=
140 | golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
141 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
142 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
143 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
144 | golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
145 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
146 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
147 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
148 | golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
149 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
150 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
151 | golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
152 | golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
153 | golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
154 | golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
155 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
156 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
157 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
158 | golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
159 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
160 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
161 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007 h1:gG67DSER+11cZvqIMb8S8bt0vZtiN6xWYARwirrOSfE=
162 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
163 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
164 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
165 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
166 | golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
167 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
168 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
169 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
170 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
171 | golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
172 | golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
173 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
174 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
175 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
176 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
177 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
178 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
179 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
180 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
181 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
182 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
183 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
184 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
185 | google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk=
186 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
187 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
188 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
189 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
190 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
191 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
192 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
193 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
194 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
195 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
196 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
197 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
198 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
199 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
200 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
201 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
202 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
203 |
--------------------------------------------------------------------------------