├── 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 | [![GitHub](https://img.shields.io/github/license/c16a/hermes)](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 | ![Baby Yoda](assets/baby_yoda.jpg){: 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 | [![Go Workflow Status](https://github.com/c16a/hermes/workflows/Go/badge.svg)](https://github.com/c16a/hermes/workflows/Go/badge.svg) 6 | 7 | [![CodeQL Workflow Status](https://github.com/c16a/hermes/workflows/CodeQL/badge.svg)](https://github.com/c16a/hermes/workflows/CodeQL/badge.svg) 8 | 9 | [![Go Report Card](https://goreportcard.com/badge/github.com/c16a/hermes)](https://goreportcard.com/report/github.com/c16a/hermes) 10 | 11 | [![Total alerts](https://img.shields.io/lgtm/alerts/g/c16a/hermes.svg?logo=lgtm&logoWidth=18)](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 | --------------------------------------------------------------------------------