From 53eb72afc233eb04a16897148c1ae15b19e9d9ec Mon Sep 17 00:00:00 2001 From: Jens Alfke Date: Wed, 27 Mar 2024 14:58:03 -0700 Subject: [PATCH 1/8] MQTT --- base/heartbeat.go | 6 +- base/http_listener.go | 26 +-- base/log_keys.go | 2 + base/parse_long_duration.go | 201 +++++++++++++++++++++ base/substitutions.go | 127 ++++++++++++++ base/substitutions_test.go | 92 ++++++++++ base/util_test.go | 14 ++ db/database.go | 26 +++ go.mod | 17 ++ go.sum | 120 +++++++++++++ mqtt/auth.go | 92 ++++++++++ mqtt/auth_hook.go | 94 ++++++++++ mqtt/auth_test.go | 100 +++++++++++ mqtt/client.go | 161 +++++++++++++++++ mqtt/cluster.go | 283 ++++++++++++++++++++++++++++++ mqtt/config.go | 250 ++++++++++++++++++++++++++ mqtt/decode_packet.go | 51 ++++++ mqtt/ingest.go | 259 +++++++++++++++++++++++++++ mqtt/modeler.go | 135 +++++++++++++++ mqtt/modeler_test.go | 42 +++++ mqtt/persist_hook.go | 196 +++++++++++++++++++++ mqtt/persister.go | 337 ++++++++++++++++++++++++++++++++++++ mqtt/publish_hook.go | 79 +++++++++ mqtt/server.go | 257 +++++++++++++++++++++++++++ mqtt/slog_adapter.go | 135 +++++++++++++++ mqtt/templater.go | 272 +++++++++++++++++++++++++++++ mqtt/templater_test.go | 89 ++++++++++ mqtt/topic_rename_hook.go | 88 ++++++++++ mqtt/topicfilter.go | 171 ++++++++++++++++++ mqtt/topicfilter_test.go | 110 ++++++++++++ mqtt/utils.go | 76 ++++++++ mqtt/utils_test.go | 90 ++++++++++ rest/config.go | 32 ++++ rest/config_legacy.go | 7 + rest/config_startup.go | 5 + rest/mqtt_server.go | 58 +++++++ rest/server_context.go | 20 +++ 37 files changed, 4108 insertions(+), 12 deletions(-) create mode 100644 base/parse_long_duration.go create mode 100644 base/substitutions.go create mode 100644 base/substitutions_test.go create mode 100644 mqtt/auth.go create mode 100644 mqtt/auth_hook.go create mode 100644 mqtt/auth_test.go create mode 100644 mqtt/client.go create mode 100644 mqtt/cluster.go create mode 100644 mqtt/config.go create mode 100644 mqtt/decode_packet.go create mode 100644 mqtt/ingest.go create mode 100644 mqtt/modeler.go create mode 100644 mqtt/modeler_test.go create mode 100644 mqtt/persist_hook.go create mode 100644 mqtt/persister.go create mode 100644 mqtt/publish_hook.go create mode 100644 mqtt/server.go create mode 100644 mqtt/slog_adapter.go create mode 100644 mqtt/templater.go create mode 100644 mqtt/templater_test.go create mode 100644 mqtt/topic_rename_hook.go create mode 100644 mqtt/topicfilter.go create mode 100644 mqtt/topicfilter_test.go create mode 100644 mqtt/utils.go create mode 100644 mqtt/utils_test.go create mode 100644 rest/mqtt_server.go diff --git a/base/heartbeat.go b/base/heartbeat.go index 2e3d57b39f..2349e10974 100644 --- a/base/heartbeat.go +++ b/base/heartbeat.go @@ -413,7 +413,6 @@ func (dh *documentBackedListener) RemoveNode(ctx context.Context, nodeID string) // Adds or removes a nodeID from the node list document func (dh *documentBackedListener) updateNodeList(ctx context.Context, nodeID string, remove bool) error { - dh.lock.Lock() defer dh.lock.Unlock() @@ -435,19 +434,22 @@ func (dh *documentBackedListener) updateNodeList(ctx context.Context, nodeID str } } + var what string if remove { // RemoveNode handling if nodeIndex == -1 { return nil // NodeID isn't part of set, doesn't need to be removed } dh.nodeIDs = append(dh.nodeIDs[:nodeIndex], dh.nodeIDs[nodeIndex+1:]...) + what = "removing" } else { // AddNode handling if nodeIndex > -1 { return nil // NodeID is already part of set, doesn't need to be added } dh.nodeIDs = append(dh.nodeIDs, nodeID) + what = "adding" } - InfofCtx(ctx, KeyCluster, "Updating nodeList document (%s) with node IDs: %v", dh.nodeListKey, dh.nodeIDs) + InfofCtx(ctx, KeyCluster, "Updating nodeList document (%s) with node IDs: %v after %s %q", dh.nodeListKey, dh.nodeIDs, what, nodeID) casOut, err := dh.datastore.WriteCas(dh.nodeListKey, 0, dh.cas, dh.nodeIDs, 0) diff --git a/base/http_listener.go b/base/http_listener.go index cc7d2e5c7b..d163ba3ace 100644 --- a/base/http_listener.go +++ b/base/http_listener.go @@ -32,27 +32,33 @@ const ( DefaultIdleTimeout = 90 * time.Second ) +// Creates a TLS config, loading the certificate and key from disk. Returns nil if certFile is empty. +func MakeTLSConfig(certFile, keyFile string, tlsMinVersion uint16) (*tls.Config, error) { + if certFile == "" { + return nil, nil + } else if cert, err := tls.LoadX509KeyPair(certFile, keyFile); err != nil { + return nil, err + } else { + return &tls.Config{ + MinVersion: tlsMinVersion, + Certificates: []tls.Certificate{cert}, + }, nil + } +} + // This is like a combination of http.ListenAndServe and http.ListenAndServeTLS, which also // uses ThrottledListen to limit the number of open HTTP connections. func ListenAndServeHTTP(ctx context.Context, addr string, connLimit uint, certFile, keyFile string, handler http.Handler, readTimeout, writeTimeout, readHeaderTimeout, idleTimeout time.Duration, http2Enabled bool, tlsMinVersion uint16) (serveFn func() error, listenerAddr net.Addr, server *http.Server, err error) { - var config *tls.Config - if certFile != "" { - config = &tls.Config{} - config.MinVersion = tlsMinVersion + config, err := MakeTLSConfig(certFile, keyFile, tlsMinVersion) + if config != nil { protocolsEnabled := []string{"http/1.1"} if http2Enabled { protocolsEnabled = []string{"h2", "http/1.1"} } config.NextProtos = protocolsEnabled InfofCtx(ctx, KeyHTTP, "Protocols enabled: %v on %v", config.NextProtos, SD(addr)) - config.Certificates = make([]tls.Certificate, 1) - var err error - config.Certificates[0], err = tls.LoadX509KeyPair(certFile, keyFile) - if err != nil { - return nil, nil, nil, err - } } // Callback that turns off TCP NODELAY option when a client transitions to a WebSocket: diff --git a/base/log_keys.go b/base/log_keys.go index 73cb0c60dc..91cc5fcf8b 100644 --- a/base/log_keys.go +++ b/base/log_keys.go @@ -49,6 +49,7 @@ const ( KeyImport KeyJavascript KeyMigrate + KeyMQTT KeyQuery KeyReplicate KeySync @@ -83,6 +84,7 @@ var ( KeyImport: "Import", KeyJavascript: "Javascript", KeyMigrate: "Migrate", + KeyMQTT: "MQTT", KeyQuery: "Query", KeyReplicate: "Replicate", KeySync: "Sync", diff --git a/base/parse_long_duration.go b/base/parse_long_duration.go new file mode 100644 index 0000000000..b024ef4c63 --- /dev/null +++ b/base/parse_long_duration.go @@ -0,0 +1,201 @@ +// Copyright 2024-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. + +package base + +import ( + "errors" + "fmt" + "time" +) + +// NOTE: This file is adapted from portions of: +// https://cs.opensource.google/go/go/%20/refs/tags/go1.22.1:src/time/format.go + +var unitMap = map[string]uint64{ + "ns": uint64(time.Nanosecond), + "us": uint64(time.Microsecond), + "µs": uint64(time.Microsecond), // U+00B5 = micro symbol + "μs": uint64(time.Microsecond), // U+03BC = Greek letter mu + "ms": uint64(time.Millisecond), + "s": uint64(time.Second), + "min": uint64(time.Minute), + "h": uint64(time.Hour), + "d": uint64(time.Hour * 24), + "w": uint64(time.Hour * 24 * 7), + "m": uint64(time.Hour * 24 * 30), + "mon": uint64(time.Hour * 24 * 30), + "y": uint64(time.Hour * 24 * 365), +} + +// ParseLongDuration is a variant of time.ParseDuration that supports longer time units. +// Valid units are "ns", "us" (or "µs"), "ms", "s", "m" (or "min"), "h", +// "d", "w", "m" (or "mon"), "y". +// +// NOTE: "m" is ambiguous. It is assumed to mean "months" unless preceded by a shorter unit; +// so for example "3m" = 3 months, but "2h30m" = 2 hours 30 minutes. +func ParseLongDuration(s string) (time.Duration, error) { + // [-+]?([0-9]*(\.[0-9]*)?[a-z]+)+ + orig := s + var d uint64 + neg := false + small_units := false + + // Consume [-+]? + if s != "" { + c := s[0] + if c == '-' || c == '+' { + neg = c == '-' + s = s[1:] + } + } + // Special case: if all that is left is "0", this is zero. + if s == "0" { + return 0, nil + } + if s == "" { + return 0, fmt.Errorf("invalid duration %q", orig) + } + for s != "" { + var ( + v, f uint64 // integers before, after decimal point + scale float64 = 1 // value = v + f/scale + ) + + var err error + + // The next character must be [0-9.] + if !(s[0] == '.' || '0' <= s[0] && s[0] <= '9') { + return 0, fmt.Errorf("invalid duration %q", orig) + } + // Consume [0-9]* + pl := len(s) + v, s, err = leadingInt(s) + if err != nil { + return 0, fmt.Errorf("invalid duration %q", orig) + } + pre := pl != len(s) // whether we consumed anything before a period + + // Consume (\.[0-9]*)? + post := false + if s != "" && s[0] == '.' { + s = s[1:] + pl := len(s) + f, scale, s = leadingFraction(s) + post = pl != len(s) + } + if !pre && !post { + // no digits (e.g. ".s" or "-.s") + return 0, fmt.Errorf("invalid duration %q", orig) + } + + // Consume unit. + i := 0 + for ; i < len(s); i++ { + c := s[i] + if c == '.' || '0' <= c && c <= '9' { + break + } + } + if i == 0 { + return 0, fmt.Errorf("missing unit in duration %q", orig) + } + u := s[:i] + s = s[i:] + unit, ok := unitMap[u] + if !ok { + return 0, fmt.Errorf("unknown unit %q in duration %q", u, orig) + } + + if !small_units { + small_units = unit <= uint64(time.Hour)*24 + } else if u == "m" { + unit = uint64(time.Minute) + } + + if v > 1<<63/unit { + // overflow + return 0, fmt.Errorf("invalid duration %q", orig) + } + v *= unit + if f > 0 { + // float64 is needed to be nanosecond accurate for fractions of hours. + // v >= 0 && (f*unit/scale) <= 3.6e+12 (ns/h, h is the largest unit) + v += uint64(float64(f) * (float64(unit) / scale)) + if v > 1<<63 { + // overflow + return 0, fmt.Errorf("invalid duration %q", orig) + } + } + d += v + if d > 1<<63 { + return 0, fmt.Errorf("invalid duration %q", orig) + } + } + if neg { + return -time.Duration(d), nil + } + if d > 1<<63-1 { + return 0, fmt.Errorf("invalid duration %q", orig) + } + return time.Duration(d), nil +} + +var errLeadingInt = errors.New("time: bad [0-9]*") // never printed + +// leadingInt consumes the leading [0-9]* from s. +func leadingInt[bytes []byte | string](s bytes) (x uint64, rem bytes, err error) { + i := 0 + for ; i < len(s); i++ { + c := s[i] + if c < '0' || c > '9' { + break + } + if x > 1<<63/10 { + // overflow + return 0, rem, errLeadingInt + } + x = x*10 + uint64(c) - '0' + if x > 1<<63 { + // overflow + return 0, rem, errLeadingInt + } + } + return x, s[i:], nil +} + +// leadingFraction consumes the leading [0-9]* from s. +// It is used only for fractions, so does not return an error on overflow, +// it just stops accumulating precision. +func leadingFraction(s string) (x uint64, scale float64, rem string) { + i := 0 + scale = 1 + overflow := false + for ; i < len(s); i++ { + c := s[i] + if c < '0' || c > '9' { + break + } + if overflow { + continue + } + if x > (1<<63-1)/10 { + // It's possible for overflow to give a positive number, so take care. + overflow = true + continue + } + y := x*10 + uint64(c) - '0' + if y > 1<<63 { + overflow = true + continue + } + x = y + scale *= 10 + } + return x, scale, s[i:] +} diff --git a/base/substitutions.go b/base/substitutions.go new file mode 100644 index 0000000000..73c8d7a37a --- /dev/null +++ b/base/substitutions.go @@ -0,0 +1,127 @@ +// Copyright 2024-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. + +package base + +import ( + "net/http" + "reflect" + "regexp" + "strconv" + "strings" +) + +// Regexp that matches either `${...}`, `$dd` where d is a digit, or a backslash-escaped `$`. +var kDollarSubstituteRegexp = regexp.MustCompile(`(\\\$)|(\$\{[^{}]*\})|(\$\w*)`) + +// Expands patterns of the form `${arg}` or `$arg` in `pattern`. +// If braces are not given, 'arg' consists only of word characters. +// Dollar signs can be escaped with backslashes: `\$` is replaced with `$`. +// +// The `replacer` function is called with the pattern minus the dollar sign and braces, +// and its return value replaces the pattern. Any error returned is passed through. +func DollarSubstitute(pattern string, replacer func(string) (string, error)) (string, error) { + if strings.IndexByte(pattern, '$') < 0 { + return pattern, nil + } + var err error + result := kDollarSubstituteRegexp.ReplaceAllStringFunc(pattern, func(matched string) string { + var replacement string + if err == nil { + if matched == "\\$" { + replacement = "$" + } else if strings.HasPrefix(matched, "${") && strings.HasSuffix(matched, "}") { + replacement, err = replacer(matched[2 : len(matched)-1]) + } else if matched != "$" { + replacement, err = replacer(matched[1:]) + } else { + err = HTTPErrorf(http.StatusInternalServerError, "invalid `$` argument in pattern: %s", pattern) + } + } + return replacement + }) + return result, err +} + +// Evaluates a "key path", like "points[3].x.y", on a JSON-based map. +// If `lenient` is true, errors relating to the input data (missing keys, etc.) are not returned +// as errors, just a nil result. +func EvalKeyPath(root map[string]any, keyPath string, lenient bool) (reflect.Value, error) { + // Handle the first path component specially because we can access `root` without reflection: + var value reflect.Value + var err error + i := strings.IndexAny(keyPath, ".[") + if i < 0 { + i = len(keyPath) + } + key := keyPath[0:i] + keyPath = keyPath[i:] + firstVal := root[key] + if firstVal == nil { + if !lenient { + err = HTTPErrorf(http.StatusInternalServerError, "parameter %q is not declared in 'args'", key) + } + return value, err + } + + value = reflect.ValueOf(firstVal) + if len(keyPath) == 0 { + return value, nil + } + + for len(keyPath) > 0 { + ch := keyPath[0] + keyPath = keyPath[1:] + if ch == '.' { + i = strings.IndexAny(keyPath, ".[") + if i < 0 { + i = len(keyPath) + } + key = keyPath[0:i] + keyPath = keyPath[i:] + + if value.Kind() != reflect.Map { + if !lenient { + err = HTTPErrorf(http.StatusBadRequest, "value is not a map") + } + return value, err + } + value = value.MapIndex(reflect.ValueOf(key)) + } else if ch == '[' { + i = strings.IndexByte(keyPath, ']') + if i < 0 { + return value, HTTPErrorf(http.StatusInternalServerError, "missing ']") + } + key = keyPath[0:i] + keyPath = keyPath[i+1:] + + index, err := strconv.ParseUint(key, 10, 8) + if err != nil { + return value, err + } + if value.Kind() != reflect.Array && value.Kind() != reflect.Slice { + if !lenient { + err = HTTPErrorf(http.StatusBadRequest, "value is a %v not an array", value.Type()) + } + return value, err + } else if uint64(value.Len()) <= index { + if !lenient { + err = HTTPErrorf(http.StatusBadRequest, "array index out of range") + } + return value, err + } + value = value.Index(int(index)) + } else { + return value, HTTPErrorf(http.StatusInternalServerError, "invalid character after a ']'") + } + for value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer { + value = value.Elem() + } + } + return value, nil +} diff --git a/base/substitutions_test.go b/base/substitutions_test.go new file mode 100644 index 0000000000..1dde113d84 --- /dev/null +++ b/base/substitutions_test.go @@ -0,0 +1,92 @@ +// Copyright 2024-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. + +package base + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUserFunctionAllow(t *testing.T) { + substitute := map[string]string{ + "args.CITY": "Paris", + "args.BREAD": "Baguette", + "wow": "huzzah", + "1234": "Onetwothreefour", + } + + replacer := func(pattern string) (string, error) { + if replacement, ok := substitute[pattern]; ok { + return replacement, nil + } else { + return "", fmt.Errorf("Unknown %q", pattern) + } + } + + result, err := DollarSubstitute("vanilla text without dollar signs", replacer) + assert.NoError(t, err) + assert.Equal(t, result, "vanilla text without dollar signs") + + // Braced: + result, err = DollarSubstitute("${args.CITY}", replacer) + assert.NoError(t, err) + assert.Equal(t, "Paris", result) + + result, err = DollarSubstitute("sales-${args.CITY}-all", replacer) + assert.NoError(t, err) + assert.Equal(t, "sales-Paris-all", result) + + result, err = DollarSubstitute("sales${args.CITY}All", replacer) + assert.NoError(t, err) + assert.Equal(t, "salesParisAll", result) + + result, err = DollarSubstitute("sales${args.CITY}${args.BREAD}", replacer) + assert.NoError(t, err) + assert.Equal(t, "salesParisBaguette", result) + + // Non-braced: + result, err = DollarSubstitute("$wow", replacer) + assert.NoError(t, err) + assert.Equal(t, "huzzah", result) + + result, err = DollarSubstitute("Say $wow!", replacer) + assert.NoError(t, err) + assert.Equal(t, "Say huzzah!", result) + + result, err = DollarSubstitute("($1234)", replacer) + assert.NoError(t, err) + assert.Equal(t, "(Onetwothreefour)", result) + + // Escaped `\$`: + result, err = DollarSubstitute(`expen\$ive`, replacer) + assert.NoError(t, err) + assert.Equal(t, "expen$ive", result) + result, err = DollarSubstitute(`\$wow`, replacer) + assert.NoError(t, err) + assert.Equal(t, "$wow", result) + result, err = DollarSubstitute(`\${wow}`, replacer) + assert.NoError(t, err) + assert.Equal(t, "${wow}", result) + + anyReplacer := func(pattern string) (string, error) { + return "XXX", nil + } + + // errors: + _, err = DollarSubstitute("foobar$", anyReplacer) + assert.Error(t, err) + _, err = DollarSubstitute("$ {wow}", anyReplacer) + assert.Error(t, err) + _, err = DollarSubstitute("knows-${args.CITY", anyReplacer) + assert.Error(t, err) + _, err = DollarSubstitute("knows-${args.CITY-${args.CITY}", anyReplacer) + assert.Error(t, err) +} diff --git a/base/util_test.go b/base/util_test.go index 8635481105..c122a5ef3c 100644 --- a/base/util_test.go +++ b/base/util_test.go @@ -1691,3 +1691,17 @@ func TestCASToLittleEndianHex(t *testing.T) { littleEndianHex := Uint64CASToLittleEndianHex(casValue) require.Equal(t, expHexValue, string(littleEndianHex)) } + +func TestParseLongDuration(t *testing.T) { + d, err := ParseLongDuration("1y2m3w4d") + require.NoError(t, err) + require.Equal(t, 24*(365+60+21+4)*time.Hour, d) + + d, err = ParseLongDuration("2h30m") + require.NoError(t, err) + require.Equal(t, 2*time.Hour+30*time.Minute, d) + + d, err = ParseLongDuration("1m2h3m") + require.NoError(t, err) + require.Equal(t, 30*24*time.Hour+2*time.Hour+3*time.Minute, d) +} diff --git a/db/database.go b/db/database.go index 9fb5773b6d..508f990ae1 100644 --- a/db/database.go +++ b/db/database.go @@ -129,6 +129,7 @@ type DatabaseContext struct { MetadataKeys *base.MetadataKeys // Factory to generate metadata document keys RequireResync base.ScopeAndCollectionNames // Collections requiring resync before database can go online CORS *auth.CORSConfig // CORS configuration + MQTTClients []MQTTClient // MQTT client connection(s) } type Scope struct { @@ -151,6 +152,7 @@ type DatabaseContextOptions struct { SessionCookieName string // Pass-through DbConfig.SessionCookieName SessionCookieHttpOnly bool // Pass-through DbConfig.SessionCookieHTTPOnly UserFunctions *UserFunctions // JS/N1QL functions clients can call + MQTT MQTTConfig // MQTT client connection & subscriptions AllowConflicts *bool // False forbids creating conflicts SendWWWAuthenticateHeader *bool // False disables setting of 'WWW-Authenticate' header DisablePasswordAuthentication bool // True enforces OIDC/guest only @@ -257,6 +259,17 @@ type ImportOptions struct { ImportPartitions uint16 // Number of partitions for import } +// Per-database MQTT configuration. (Implemented by mqtt.PerDBConfig) +type MQTTConfig interface { + IsEnabled() bool + Start(ctx context.Context, dbc *DatabaseContext) ([]MQTTClient, error) +} + +// Represents a running MQTT client. (Implemented by mqtt.Client) +type MQTTClient interface { + Stop() error +} + // Represents a simulated CouchDB database. A new instance is created for each HTTP request, // so this struct does not have to be thread-safe. type Database struct { @@ -547,6 +560,15 @@ func NewDatabaseContext(ctx context.Context, dbName string, bucket base.Bucket, dbContext.ResyncManager = NewResyncManagerDCP(metadataStore, dbContext.UseXattrs(), metaKeys) } + // Start MQTT client connection(s) + if options.MQTT != nil && options.MQTT.IsEnabled() { + dbContext.MQTTClients, err = options.MQTT.Start(ctx, dbContext) + if err != nil { + //TODO: Should this be fatal, or should it just log an error? + return nil, fmt.Errorf("couldn't start MQTT client: %w", err) + } + } + return dbContext, nil } @@ -572,6 +594,10 @@ func (context *DatabaseContext) Close(ctx context.Context) { context.BucketLock.Lock() defer context.BucketLock.Unlock() + for _, client := range context.MQTTClients { + client.Stop() + } + context.OIDCProviders.Stop() close(context.terminator) diff --git a/go.mod b/go.mod index 4a79bbad14..868743c078 100644 --- a/go.mod +++ b/go.mod @@ -16,12 +16,16 @@ require ( github.com/couchbaselabs/go-fleecedelta v0.0.0-20220909152808-6d09efa7a338 github.com/couchbaselabs/gocbconnstr v1.0.5 github.com/couchbaselabs/rosmar v0.0.0-20240516145123-749ae63effda + github.com/eclipse/paho.golang v0.21.0 github.com/elastic/gosigar v0.14.3 github.com/felixge/fgprof v0.9.4 github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 + github.com/hashicorp/memberlist v0.5.0 github.com/json-iterator/go v1.1.12 github.com/kardianos/service v1.2.2 + github.com/mmcloughlin/geohash v0.10.0 + github.com/mochi-mqtt/server/v2 v2.6.3 github.com/natefinch/lumberjack v2.0.0+incompatible github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.16.0 @@ -42,6 +46,7 @@ require ( require ( github.com/BurntSushi/toml v1.3.2 // indirect + github.com/armon/go-metrics v0.4.1 // indirect github.com/aws/aws-sdk-go v1.44.299 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect @@ -60,12 +65,22 @@ require ( github.com/go-ole/go-ole v1.2.6 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v0.0.4 // indirect + github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c // indirect github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7 // indirect + github.com/gorilla/websocket v1.5.1 // indirect github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect + github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/hashicorp/go-immutable-radix v1.3.1 // indirect + github.com/hashicorp/go-msgpack v1.1.5 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-sockaddr v1.0.2 // indirect + github.com/hashicorp/go-uuid v1.0.1 // indirect + github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/klauspost/compress v1.17.3 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/miekg/dns v1.1.41 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -74,6 +89,8 @@ require ( github.com/prometheus/procfs v0.10.1 // indirect github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect + github.com/rs/xid v1.4.0 // indirect + github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect github.com/sergi/go-diff v1.2.0 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/stretchr/objx v0.5.2 // indirect diff --git a/go.sum b/go.sum index 4bc8680888..ee6a5d1afc 100644 --- a/go.sum +++ b/go.sum @@ -10,12 +10,25 @@ github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.0.0/go.mod h1:2e8rMJtl2+ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= +github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/aws/aws-sdk-go v1.44.299 h1:HVD9lU4CAFHGxleMJp95FV/sRhtg7P4miHD1v88JAQk= github.com/aws/aws-sdk-go v1.44.299/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= @@ -26,6 +39,8 @@ github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwys github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= +github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/coreos/go-oidc v2.2.1+incompatible h1:mh48q/BqXqgjVHpy2ZY7WnWAbenxRjsz9N1i1YxjHAk= @@ -79,15 +94,22 @@ github.com/couchbaselabs/rosmar v0.0.0-20240516145123-749ae63effda/go.mod h1:R/F github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/eclipse/paho.golang v0.21.0 h1:cxxEReu+iFbA5RrHfRGxJOh8tXZKDywuehneoeBeyn8= +github.com/eclipse/paho.golang v0.21.0/go.mod h1:GHF6vy7SvDbDHBguaUpfuBkEB5G6j0zKxMG4gbh6QRQ= github.com/elastic/gosigar v0.14.3 h1:xwkKwPia+hSfg9GqrCUKYdId102m9qTJIIr7egmK/uo= github.com/elastic/gosigar v0.14.3/go.mod h1:iXRIGg2tLnu7LBdpqzyQfGDEidKCfWcCMS0WKyPWoMs= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/felixge/fgprof v0.9.4 h1:ocDNwMFlnA0NU0zSB3I52xkO4sFXk80VK9lXjLClu88= github.com/felixge/fgprof v0.9.4/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= @@ -95,10 +117,12 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= @@ -106,7 +130,11 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c h1:964Od4U6p2jUkFxvCydnIczKteheJEzHRToSGK3Bnlw= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= @@ -118,15 +146,46 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-msgpack v1.1.5 h1:9byZdVjKTe5mce63pRVNP1L7UAmdHOTEMGehn6KvJWs= +github.com/hashicorp/go-msgpack v1.1.5/go.mod h1:gWVc3sv/wbDmR3rQsj1CAktEZzoz1YNK9NfGLXJ69/4= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= +github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1 h1:fv1ep09latC32wFoVwnqcnKJGnMSdBanPczbHAYm1BE= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/memberlist v0.5.0 h1:EtYPN8DpAURiapus508I4n9CzHs2W+8NZGbmmR/prTM= +github.com/hashicorp/memberlist v0.5.0/go.mod h1:yvyXLpo0QaGE59Y7hDTsTzDD25JYBZ4mHgHUZ8lrOI0= github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= +github.com/jinzhu/copier v0.3.5 h1:GlvfUwHk62RokgqVNvYsku0TATCF7bAHVwEXoBh3iJg= +github.com/jinzhu/copier v0.3.5/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kardianos/service v1.2.2 h1:ZvePhAHfvo0A7Mftk/tEzqEZ7Q4lgnR8sGz4xu1YX60= github.com/kardianos/service v1.2.2/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -134,6 +193,7 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/klauspost/compress v1.17.3 h1:qkRjuerhUU1EmXLYGkSH6EZL+vPSxIrYjLNAK4slzwA= github.com/klauspost/compress v1.17.3/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -145,35 +205,66 @@ github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1 github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= +github.com/miekg/dns v1.1.41 h1:WMszZWJG0XmzbK9FEmzH2TVcqYzFesusSIB41b8KHxY= +github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/mmcloughlin/geohash v0.10.0 h1:9w1HchfDfdeLc+jFEf/04D27KP7E2QmpDu52wPbJWRE= +github.com/mmcloughlin/geohash v0.10.0/go.mod h1:oNZxQo5yWJh0eMQEP/8hwQuVx9Z9tjwFUqcTB1SmG0c= +github.com/mochi-mqtt/server/v2 v2.6.3 h1:LaaeGXkVH/1igCl9QYGTFzFb01E9RzKnIB8xUHGX/y8= +github.com/mochi-mqtt/server/v2 v2.6.3/go.mod h1:TqztjKGO0/ArOjJt9x9idk0kqPT3CVN8Pb+l+PS5Gdo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4UpgHp07nNdFX7mqFfM= github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= +github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/pquerna/cachecontrol v0.2.0 h1:vBXSNuE5MYP9IJ5kjsdo8uq+w41jSPgvba2DEnkRx9k= github.com/pquerna/cachecontrol v0.2.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg= github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= github.com/quasilyte/go-ruleguard/dsl v0.3.22 h1:wd8zkOhSNr+I+8Qeciml08ivDt1pSXe60+5DqOpCjPE= @@ -184,8 +275,13 @@ github.com/robertkrimen/otto v0.0.0-20211024170158-b87d35c0b86f h1:a7clxaGmmqtdN github.com/robertkrimen/otto v0.0.0-20211024170158-b87d35c0b86f/go.mod h1:/mK7FZ3mFYEn9zvNPhpngTyatyehSwte5bJZ4ehL5Xw= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY= +github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/samuel/go-metrics v0.0.0-20150819231912-7ccf3e0e1fb1 h1:UrKfubnICxHKc+p+6ePllH2U6FYR5zI+v9r3vWqDSdc= github.com/samuel/go-metrics v0.0.0-20150819231912-7ccf3e0e1fb1/go.mod h1:9x9QHDfTzYlEhqmR5TGV3oelYSs2Fmlq/AnrFurKH2g= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= @@ -196,6 +292,7 @@ github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFt github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -218,6 +315,7 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a h1:fZHgsYlfvtyqToslyjUt3VOPF4J7aK/3MPcK7xp3PDk= github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a/go.mod h1:ul22v+Nro/R083muKhosV54bj5niojjWZvU8xrevuH4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -235,7 +333,9 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8 go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -254,10 +354,13 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -274,22 +377,33 @@ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180810173357-98c5dad5d1a0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -301,6 +415,7 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= @@ -312,7 +427,9 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190424220101-1e8e1cfdf96b/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= @@ -338,6 +455,7 @@ google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -350,8 +468,10 @@ gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI= gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78= gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/mqtt/auth.go b/mqtt/auth.go new file mode 100644 index 0000000000..e5b0acbda2 --- /dev/null +++ b/mqtt/auth.go @@ -0,0 +1,92 @@ +// Copyright 2024-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. + +package mqtt + +import ( + "context" + + "github.com/couchbase/sync_gateway/base" + "github.com/couchbase/sync_gateway/channels" +) + +// Highly-simplified subset of auth.User; +// contains just the methods this package needs, for easier unit testing. +type User interface { + Name() string + RoleNames() channels.TimedSet + CanSeeCollectionChannel(scope, collection, channel string) bool +} + +//======== AUTH: + +// Finds a TopicConfig in BrokerConfig.Allow whose topic filter matches a topic name. +// Returns the TopicConfig and the list of matches against any wildcards in its filter. +func (bc *BrokerConfig) Authorize(user User, topic string, write bool) bool { + bc.mutex.Lock() + defer bc.mutex.Unlock() + + if bc.allowFilters == nil { + var filters TopicMap[*TopicConfig] + for _, tc := range bc.Allow { + if err := filters.AddFilter(tc.Topic, tc); err != nil { + return false // should have been caught in validation + } + } + bc.allowFilters = &filters + } + + if topicConfig, match := bc.allowFilters.Match(topic); topicConfig == nil { + return false + } else if write { + return topicConfig.Publish.Authorize(user, *match) + } else { + return topicConfig.Subscribe.Authorize(user, *match) + } +} + +// Returns true if the ACLConfig allows this user based on username, role, or channel membership. +// `$1` variables will be expanded based on the TopicMatch. +func (acl *ACLConfig) Authorize(user User, topic TopicMatch) bool { + // Check if the username is allowed: + username := user.Name() + for _, userPattern := range acl.Users { + if userPattern == "*" { + return true // "*" matches anyone + } else if userPattern == "!" && username != "" { + return true // "!" matches any non-guest + } else if expanded, err := topic.ExpandPattern(userPattern); err != nil { + base.WarnfCtx(context.Background(), "MQTT: invalid username pattern %q", userPattern) + } else if expanded == user.Name() { + return true // User name is in the allowed list + } + } + + // Check if the user is in one of the allowed roles. + userRoles := user.RoleNames() + for _, rolePattern := range acl.Roles { + if role, err := topic.ExpandPattern(rolePattern); err != nil { + base.WarnfCtx(context.Background(), "MQTT: invalid role pattern %q", rolePattern) + } else if userRoles.Contains(role) { + return true // User has one of the allowed roles + } + } + + // Check if the user has access to one of the allowed channels. + for _, channelPattern := range acl.Channels { + if channelPattern == channels.AllChannelWildcard { + return true + } else if channel, err := topic.ExpandPattern(channelPattern); err != nil { + base.WarnfCtx(context.Background(), "MQTT: invalid channel pattern %q", channelPattern) + } else if user.CanSeeCollectionChannel(base.DefaultScope, base.DefaultCollection, channel) { + return true // User has access to one of the allowed channels + } + } + + return false +} diff --git a/mqtt/auth_hook.go b/mqtt/auth_hook.go new file mode 100644 index 0000000000..b66508d2c2 --- /dev/null +++ b/mqtt/auth_hook.go @@ -0,0 +1,94 @@ +// Copyright 2024-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. + +package mqtt + +import ( + "context" + "slices" + + "github.com/couchbase/sync_gateway/base" + mochi "github.com/mochi-mqtt/server/v2" + "github.com/mochi-mqtt/server/v2/packets" +) + +// Hook that uses SG's user database for authentication. +type authHook struct { + mochi.HookBase + ctx context.Context + server *Server +} + +func newAuthHook(server *Server) *authHook { + return &authHook{ctx: server.ctx, server: server} +} + +// ID returns the ID of the hook. +func (h *authHook) ID() string { + return "SGAuthHook" +} + +// Provides indicates which hook methods this hook provides. +func (h *authHook) Provides(b byte) bool { + return b == mochi.OnConnectAuthenticate || b == mochi.OnACLCheck +} + +// Authenticates a connection request. +func (h *authHook) OnConnectAuthenticate(client *mochi.Client, pk packets.Packet) bool { + defer base.FatalPanicHandler() + dbc, username := h.server.clientDatabaseContext(client) + if dbc == nil { + base.WarnfCtx(h.ctx, "MQTT connection attempt failed with username %q -- does not begin with a database name", client.Properties.Username) + return false + } + + user, _ := dbc.Authenticator(h.ctx).AuthenticateUser(username, string(pk.Connect.Password)) + if user == nil { + base.WarnfCtx(h.ctx, "MQTT auth failure for username %q", client.Properties.Username) + return false + } + + existing, _ := h.server.broker.Clients.Get(pk.Connect.ClientIdentifier) + if existing != nil && !slices.Equal(existing.Properties.Username, client.Properties.Username) { + // This connection ID is already in use by a different user's client. + // If we continue, the broker will take over that session, which is a possible + // security issue: this client would inherit its topic subscriptions. + base.WarnfCtx(h.ctx, "MQTT auth failure for username %q: reusing session ID %q already belonging to user %q", + client.Properties.Username, pk.Connect.ClientIdentifier, existing.Properties.Username) + return false + } + + base.InfofCtx(h.ctx, base.KeyMQTT, "Client connection by user %q to db %q (session ID %q)", username, dbc.Name, client.ID) + return true +} + +// Authorizes access to a topic. +func (h *authHook) OnACLCheck(client *mochi.Client, topic string, write bool) (allowed bool) { + defer base.FatalPanicHandler() + // The topic name has to have the DB name as its first component: + dbc, username := h.server.clientDatabaseContext(client) + topic, ok := stripDbNameFromTopic(dbc, topic) + if !ok { + base.WarnfCtx(h.ctx, "MQTT: DB %s user %q tried to access topic %q not in that DB", dbc.Name, username, topic) + return false + } + + user, err := dbc.Authenticator(h.ctx).GetUser(username) + if err != nil { + base.WarnfCtx(h.ctx, "MQTT: OnACLCheck: Can't find DB user for MQTT client username %q", username) + return false + } + + allowed = dbcSettings(dbc).Authorize(user, topic, write) + if allowed { + base.InfofCtx(h.ctx, base.KeyMQTT, "DB %s user %q accessing topic %q, write=%v", dbc.Name, username, topic, write) + } else { + base.InfofCtx(h.ctx, base.KeyMQTT, "DB %s user %q blocked from accessing topic %q, write=%v", dbc.Name, username, topic, write) + } + return +} diff --git a/mqtt/auth_test.go b/mqtt/auth_test.go new file mode 100644 index 0000000000..77dd9dfa59 --- /dev/null +++ b/mqtt/auth_test.go @@ -0,0 +1,100 @@ +// Copyright 2024-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. + +package mqtt + +import ( + "testing" + + "github.com/couchbase/sync_gateway/base" + "github.com/couchbase/sync_gateway/channels" + "github.com/stretchr/testify/require" +) + +//======== MOCK USER + +// Mock implementatin of User +type MockUser struct { + name string + roleNames channels.TimedSet + channels base.Set +} + +func MakeMockUser(name string, roleNames []string, channelNames []string) *MockUser { + return &MockUser{ + name: name, + roleNames: channels.AtSequence(base.SetFromArray(roleNames), 1234), + channels: base.SetFromArray(channelNames), + } +} + +func (user *MockUser) Name() string { return user.name } + +func (user *MockUser) RoleNames() channels.TimedSet { return user.roleNames } + +func (user *MockUser) CanSeeCollectionChannel(scope, collection, channel string) bool { + return scope == base.DefaultScope && collection == base.DefaultCollection && user.channels.Contains(channel) +} + +var _ User = &MockUser{} + +//======== TESTS + +func TestAuth(t *testing.T) { + config := BrokerConfig{ + Allow: []*TopicConfig{ + {Topic: "anyone", + Publish: ACLConfig{ + Users: []string{"*"}, + }, + }, + {Topic: "personal/+/#", + Publish: ACLConfig{ + Users: []string{"$1"}, + }, + Subscribe: ACLConfig{ + Users: []string{"$1"}, + Roles: []string{"high priest"}, + }, + }, + {Topic: "stocks", + Publish: ACLConfig{ + Channels: []string{"NBC"}, + }, + Subscribe: ACLConfig{ + Channels: []string{"ABC", "NBC"}, + }, + }, + {Topic: "network/+", + Publish: ACLConfig{ + Channels: []string{"$1"}, + }, + }, + }, + } + + require.NoError(t, config.Validate()) + + jeff := MakeMockUser("jeff", []string{"minion"}, []string{"ESPN"}) + amy := MakeMockUser("amy", []string{"high priest"}, []string{"ABC", "PBS"}) + + require.False(t, config.Authorize(jeff, "missingTopic", false)) + require.True(t, config.Authorize(jeff, "anyone", true)) + + require.True(t, config.Authorize(jeff, "personal/jeff/howdy", false)) + require.True(t, config.Authorize(jeff, "personal/jeff/howdy", true)) + require.True(t, config.Authorize(amy, "personal/jeff/howdy", false)) + require.False(t, config.Authorize(amy, "personal/jeff/howdy", true)) + + require.True(t, config.Authorize(amy, "stocks", false)) + require.False(t, config.Authorize(amy, "stocks", true)) + + require.True(t, config.Authorize(amy, "network/PBS", true)) + require.False(t, config.Authorize(amy, "network/ESPN", true)) + +} diff --git a/mqtt/client.go b/mqtt/client.go new file mode 100644 index 0000000000..aedeeea8cd --- /dev/null +++ b/mqtt/client.go @@ -0,0 +1,161 @@ +// Copyright 2024-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. + +package mqtt + +import ( + "context" + "fmt" + "net/url" + + "github.com/couchbase/sync_gateway/base" + "github.com/couchbase/sync_gateway/db" + "github.com/eclipse/paho.golang/autopaho" + "github.com/eclipse/paho.golang/paho" +) + +//======== MQTT CLIENT: + +// MQTT client for Sync Gateway. (Implements db.MQTTClient) +type Client struct { + ctx context.Context // Context + config *ClientConfig // Configuration + subs TopicMap[*IngestConfig] // Maps topic filters to SubscribeConfig + database *db.DatabaseContext // Database + conn *autopaho.ConnectionManager // Actual MQTT client + cancelFunc context.CancelFunc // Call this to stop `conn` +} + +func (config *PerDBConfig) Start(ctx context.Context, dbc *db.DatabaseContext) ([]db.MQTTClient, error) { + var clients []db.MQTTClient + for _, cfg := range config.Clients { + client, err := StartClient(ctx, cfg, dbc) + if err != nil { + return nil, err + } + clients = append(clients, client) + } + return clients, nil +} + +// Creates and starts an MQTT client. +func StartClient(ctx context.Context, config *ClientConfig, dbc *db.DatabaseContext) (*Client, error) { + if !config.IsEnabled() { + return nil, nil + } + + client := &Client{ + ctx: ctx, + config: config, + database: dbc, + } + + brokerURL, err := url.Parse(config.Broker.URL) + if err != nil { + return nil, fmt.Errorf("invalid MQTT broker URL %v", config.Broker.URL) + } + + client.subs, err = MakeTopicMap(config.Ingest) + if err != nil { + return nil, err + } + + pahoCfg := autopaho.ClientConfig{ + ServerUrls: []*url.URL{brokerURL}, + CleanStartOnInitialConnection: false, // Use persistent connection state + KeepAlive: 20, // Interval (secs) to send keepalive messages + SessionExpiryInterval: 3600, // Seconds that a session will survive after disconnection. + + OnConnectionUp: client.onConnectionUp, + OnConnectError: client.onConnectError, + + ClientConfig: paho.ClientConfig{ + ClientID: config.Broker.ClientID, + OnClientError: client.onClientError, + OnServerDisconnect: client.onServerDisconnect, + OnPublishReceived: []func(paho.PublishReceived) (bool, error){client.onPublishReceived}, + }, + } + + pahoCtx, cancelFunc := context.WithCancel(ctx) + client.conn, err = autopaho.NewConnection(pahoCtx, pahoCfg) + client.cancelFunc = cancelFunc + return client, err +} + +// Stops the MQTT client. +func (client *Client) Stop() error { + if cf := client.cancelFunc; cf != nil { + base.InfofCtx(client.ctx, base.KeyMQTT, "Stopping client %q", client.config.Broker.ClientID) + client.cancelFunc = nil + cf() + } + return nil +} + +//======= HANDLER METHODS: + +// Called after connecting to the broker. +func (client *Client) onConnectionUp(cm *autopaho.ConnectionManager, connAck *paho.Connack) { + base.InfofCtx(client.ctx, base.KeyMQTT, "Client connected to %s as %q", client.config.Broker.URL, client.config.Broker.ClientID) + // Subscribing in the OnConnectionUp callback is recommended (ensures the subscription is + // reestablished if the connection drops) + var sub paho.Subscribe + for topicPat, opt := range client.config.Ingest { + qos := byte(0) + if opt.QoS != nil { + qos = byte(*opt.QoS) + } + sub.Subscriptions = append(sub.Subscriptions, paho.SubscribeOptions{Topic: topicPat, QoS: qos}) + } + + if _, err := cm.Subscribe(client.ctx, &sub); err != nil { + base.ErrorfCtx(client.ctx, "Client %q failed to subscribe: %v", client.config.Broker.ClientID, err) + } +} + +func (client *Client) onConnectError(err error) { + base.ErrorfCtx(client.ctx, "MQTT Client %q failed to connect to %s: %v", client.config.Broker.ClientID, client.config.Broker.URL, err) +} + +func (client *Client) onClientError(err error) { + base.ErrorfCtx(client.ctx, "MQTT Client %q got an error from %s: %v", client.config.Broker.ClientID, client.config.Broker.URL, err) +} + +func (client *Client) onServerDisconnect(d *paho.Disconnect) { + base.WarnfCtx(client.ctx, "MQTT Client %q disconnected from %s; reason=%d", client.config.Broker.ClientID, client.config.Broker.URL, d.ReasonCode) +} + +// Handles an incoming MQTT message from a subscription. +func (client *Client) onPublishReceived(pub paho.PublishReceived) (bool, error) { + base.InfofCtx(client.ctx, base.KeyMQTT, "Client %q received message in topic %s", client.config.Broker.ClientID, pub.Packet.Topic) + + // Look up the subscription: + sub, match := client.subs.Match(pub.Packet.Topic) + if sub == nil { + base.WarnfCtx(client.ctx, "MQTT Client %q received message in unexpected topic %q", client.config.Broker.ClientID, pub.Packet.Topic) + return false, nil + } + + var exp uint32 = 0 + if msgExp := pub.Packet.Properties.MessageExpiry; msgExp != nil { + exp = base.SecondsToCbsExpiry(int(*msgExp)) + } + + err := IngestMessage(client.ctx, *match, pub.Packet.Payload, sub, client.database, exp) + if err != nil { + base.WarnfCtx(client.ctx, "MQTT Client %q failed to save message from topic %q: %v", client.config.Broker.ClientID, pub.Packet.Topic, err) + } + return (err == nil), err +} + +var ( + // Enforce interface conformance: + _ db.MQTTConfig = &PerDBConfig{} + _ db.MQTTClient = &Client{} +) diff --git a/mqtt/cluster.go b/mqtt/cluster.go new file mode 100644 index 0000000000..82b92f6cdf --- /dev/null +++ b/mqtt/cluster.go @@ -0,0 +1,283 @@ +// Copyright 2024-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. + +package mqtt + +import ( + "bytes" + "context" + "fmt" + "net" + "slices" + "strconv" + "time" + + sgbucket "github.com/couchbase/sg-bucket" + "github.com/couchbase/sync_gateway/base" + "github.com/hashicorp/memberlist" + mochi "github.com/mochi-mqtt/server/v2" + "github.com/mochi-mqtt/server/v2/packets" +) + +const kDefaultDiscoveryPort = 7946 +const kHeartbeatKeyPrefix = "_sync:MQTT_Discovery" + +// Manages communication with other brokers in the cluster. +type clusterAgent struct { + ctx context.Context + clusteringID string + metadataStore sgbucket.DataStore // Bucket that stores broker metadata + broker *mochi.Server + heartbeater base.Heartbeater // Publishes my address for other nodes to discover on startup + getPeerNodesFunc func() ([]string, error) + peers *memberlist.Memberlist + broadcastQueue memberlist.TransmitLimitedQueue +} + +func startClusterAgent(server *Server) (agent *clusterAgent, err error) { + // Determine which ports to use for cluster membership and Raft: + clusterCfg := server.config.Cluster + if clusterCfg == nil { + return + } else if !clusterCfg.IsEnabled() { + base.InfofCtx(server.ctx, base.KeyMQTT, "MQTT clustering is disabled per config") + return + } + + discoveryAddr := clusterCfg.DiscoveryAddr + discoveryPort := kDefaultDiscoveryPort + if discoveryAddr != "" { + var portStr string + discoveryAddr, portStr, err = net.SplitHostPort(discoveryAddr) + if err != nil { + return nil, fmt.Errorf("couldn't parse cluster.discovery_addr %q", clusterCfg.DiscoveryAddr) + } + discoveryPort, _ = strconv.Atoi(portStr) + } + if discoveryAddr == "" { + discoveryAddr = "0.0.0.0" + } + + agent = &clusterAgent{ + ctx: server.ctx, + clusteringID: net.JoinHostPort(server.host, strconv.Itoa(discoveryPort)), + metadataStore: server.persister.metadataStore, + broker: server.broker, + } + + if err = agent.startHeartbeat(); err != nil { + return nil, fmt.Errorf("error starting Heartbeater: %w", err) + } + + defer func() { + if err != nil { + agent.stop() + agent = nil + } + }() + + peerAddrs, err := agent.getPeerAddresses() + if err != nil { + return + } + base.InfofCtx(agent.ctx, base.KeyMQTT, "Starting MQTT cluster agent %s, binding to %s; known peers: %v", agent.clusteringID, discoveryAddr, peerAddrs) + + // Create the Memberlist: + memberCfg := memberlist.DefaultLANConfig() + memberCfg.Name = agent.clusteringID + memberCfg.BindAddr = discoveryAddr + memberCfg.BindPort = discoveryPort + memberCfg.Delegate = agent + memberCfg.Events = agent + memberCfg.Logger = newLogLogger(server.ctx, base.KeyMQTT, "") + agent.peers, err = memberlist.Create(memberCfg) + if err != nil { + return + } + + agent.broadcastQueue.NumNodes = agent.peers.NumMembers + agent.broadcastQueue.RetransmitMult = 1 // ???? + + if len(peerAddrs) > 0 { + n, _ := agent.peers.Join(peerAddrs) + base.InfofCtx(agent.ctx, base.KeyMQTT, "Cluster agent made contact with %d peers", n) + } + return +} + +// Shuts down the cluster agent. +func (agent *clusterAgent) stop() { + if agent.peers != nil { + base.InfofCtx(agent.ctx, base.KeyMQTT, "Cluster agent is saying goodbye...") + agent.peers.Leave(5 * time.Second) + agent.peers.Shutdown() + agent.peers = nil + } + if agent.heartbeater != nil { + agent.heartbeater.Stop(agent.ctx) + agent.heartbeater = nil + } +} + +//======== HEARTBEAT STUFF + +// Starts the `Heartbeater`, which lets new nodes discover this one so they can join the cluster. +// This shouldn't be called until the MQTT broker is up and clustered. +func (agent *clusterAgent) startHeartbeat() (err error) { + base.InfofCtx(agent.ctx, base.KeyMQTT, "Starting Heartbeater") + // Initialize node heartbeater. This node must start sending heartbeats before registering + // itself to the cfg, to avoid triggering immediate removal by other active nodes. + heartbeater, err := base.NewCouchbaseHeartbeater(agent.metadataStore, kHeartbeatKeyPrefix, agent.clusteringID) + if err != nil { + return + } else if err = heartbeater.Start(agent.ctx); err != nil { + return + } + + defer func() { + if err != nil { + heartbeater.Stop(agent.ctx) + } + }() + + listener, err := base.NewDocumentBackedListener(agent.metadataStore, kHeartbeatKeyPrefix) + if err != nil { + return + } else if err = listener.AddNode(agent.ctx, agent.clusteringID); err != nil { + return + } else if err = heartbeater.RegisterListener(listener); err != nil { + return + } + + agent.heartbeater = heartbeater + agent.getPeerNodesFunc = listener.GetNodes + return +} + +// Gets a list of addresses of other SG nodes in the MQTT cluster +func (agent *clusterAgent) getPeerAddresses() ([]string, error) { + peers, err := agent.getPeerNodesFunc() + if peers == nil { + peers = []string{} + } else { + peers = slices.Clone(peers) // the original array belongs to the Heartbeater + peers = slices.DeleteFunc(peers, func(a string) bool { + return a == agent.clusteringID + }) + } + return peers, err +} + +//======== PACKET BROADCAST + +func (agent *clusterAgent) broadcastPublish(packet *packets.Packet) error { + if agent.peers.NumMembers() > 0 { + var buf bytes.Buffer + buf.WriteByte('P') // means Publish + buf.WriteByte(packet.ProtocolVersion) + if err := packet.PublishEncode(&buf); err != nil { + return err + } + b := &packetBroadcast{data: buf.Bytes()} + base.InfofCtx(agent.ctx, base.KeyMQTT, "Broadcasting Publish packet for topic %q", packet.TopicName) + agent.broadcastQueue.QueueBroadcast(b) + } + return nil +} + +type packetBroadcast struct { + data []byte +} + +func (b *packetBroadcast) Invalidates(other memberlist.Broadcast) bool { + return false +} + +// Returns a byte form of the message +func (b *packetBroadcast) Message() []byte { + return b.data +} + +// Finished is invoked when the message will no longer +// be broadcast, either due to invalidation or to the +// transmit limit being reached +func (b *packetBroadcast) Finished() {} + +//======== MEMBERLIST DELEGATE + +// NodeMeta is used to retrieve meta-data about the current node +// when broadcasting an alive message. It's length is limited to +// the given byte size. This metadata is available in the Node structure. +func (agent *clusterAgent) NodeMeta(limit int) []byte { + return nil +} + +// NotifyMsg is called when a user-data message is received. +// Care should be taken that this method does not block, since doing +// so would block the entire UDP packet receive loop. Additionally, the byte +// slice may be modified after the call returns, so it should be copied if needed +func (agent *clusterAgent) NotifyMsg(message []byte) { + if len(message) <= 1 { + base.ErrorfCtx(agent.ctx, "MQTT cluster received empty message") + return + } + switch message[0] { + case 'P': + // This is a Publish packet + if packet, err := decodePacket(message[2:], message[1]); err == nil { + base.InfofCtx(agent.ctx, base.KeyMQTT, "Relaying PUBLISH packet from peer for topic %q (%d bytes)", packet.TopicName, len(packet.Payload)) + agent.broker.Publish(packet.TopicName, packet.Payload, packet.FixedHeader.Retain, packet.FixedHeader.Qos) + } else { + base.ErrorfCtx(agent.ctx, "MQTT cluster error decoding packet: %v", err) + } + default: + base.ErrorfCtx(agent.ctx, "MQTT cluster received message with unknown start byte '%c'", message[0]) + } +} + +// GetBroadcasts is called when user data messages can be broadcast. +// It can return a list of buffers to send. Each buffer should assume an +// overhead as provided with a limit on the total byte size allowed. +// The total byte size of the resulting data to send must not exceed +// the limit. Care should be taken that this method does not block, +// since doing so would block the entire UDP packet receive loop. +func (agent *clusterAgent) GetBroadcasts(overhead, limit int) [][]byte { + return agent.broadcastQueue.GetBroadcasts(overhead, limit) +} + +// LocalState is used for a TCP Push/Pull. This is sent to +// the remote side in addition to the membership information. Any +// data can be sent here. See MergeRemoteState as well. The `join` +// boolean indicates this is for a join instead of a push/pull. +func (agent *clusterAgent) LocalState(join bool) []byte { + return nil +} + +// MergeRemoteState is invoked after a TCP Push/Pull. This is the +// state received from the remote side and is the result of the +// remote side's LocalState call. The 'join' +// boolean indicates this is for a join instead of a push/pull. +func (agent *clusterAgent) MergeRemoteState(buf []byte, join bool) { +} + +//======== MEMBERLIST EVENTS INTERFACE + +// NotifyJoin is invoked when a node is detected to have joined. +func (agent *clusterAgent) NotifyJoin(node *memberlist.Node) { + base.InfofCtx(agent.ctx, base.KeyMQTT, "Cluster node %s joined", node.Name) +} + +// NotifyLeave is invoked when a node is detected to have left. +func (agent *clusterAgent) NotifyLeave(node *memberlist.Node) { + base.InfofCtx(agent.ctx, base.KeyMQTT, "Cluster node %s left", node.Name) +} + +// NotifyUpdate is invoked when a node is detected to have +// updated, usually involving the meta data. +func (agent *clusterAgent) NotifyUpdate(node *memberlist.Node) { +} diff --git a/mqtt/config.go b/mqtt/config.go new file mode 100644 index 0000000000..1651d85e7f --- /dev/null +++ b/mqtt/config.go @@ -0,0 +1,250 @@ +// Copyright 2024-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. + +package mqtt + +import ( + "fmt" + "net/url" + "sync" + "time" + + sgbucket "github.com/couchbase/sg-bucket" + "github.com/couchbase/sync_gateway/base" +) + +// Per-database MQTT configuration. (Implements db.MQTTConfig) +type PerDBConfig struct { + Enabled *bool `json:"enabled,omitempty"` + Clients []*ClientConfig `json:"clients,omitempty"` + Broker *BrokerConfig `json:"broker,omitempty"` +} + +//======== PER-DB MQTT CLIENT CONFIG + +// Configuration for MQTT client +type ClientConfig struct { + Enabled *bool `json:"enabled,omitempty"` // `false` to disable client + Broker RemoteBrokerConfig `json:"broker"` // How to connect to the broker + Ingest IngestMap `json:"ingest"` // Topics to subscribe to (key is topic filter) +} + +// Location & credentials for connecting to a broker as a client +type RemoteBrokerConfig struct { + URL string `json:"url"` // Broker URL to connect to (mqtt:, mqtts:, ws:, wss:) + User *string `json:"user"` // Username to log in with + Password *string `json:"password"` // Password to log in with + ClientID string `json:"client_id,omitempty"` // Client ID; must be unique per SG node +} + +//TODO: Support client-cert auth; any others? + +//======== PER-DB MQTT BROKER/SERVER CONFIG + +// Configuration for a DB being served via SG's MQTT broker. +type BrokerConfig struct { + Enabled *bool `json:"enabled,omitempty"` // False to disable broker + Allow []*TopicConfig `json:"allow,omitempty"` // Maps topic filter -> ACLs + Ingest IngestMap `json:"ingest,omitempty"` // Topics to save as docs + mutex sync.Mutex `json:"-"` // Lock to access fields below + allowFilters *TopicMap[*TopicConfig] `json:"-"` // Compiled from 'Allow' + ingestFilters *TopicMap[*IngestConfig] `json:"-"` // Compiled from 'Ingest' +} + +// ACLs for user access to served topics +type TopicConfig struct { + Topic string `json:"topic"` // Topic name or filter + Publish ACLConfig `json:"publish"` // ACLs for publishing to this topic + Subscribe ACLConfig `json:"subscribe,omitempty"` // ACLs for subscribing to this topic +} + +type ACLConfig struct { + Channels []string `json:"channels,omitempty"` // Users with access to these channels are allowed + Roles []string `json:"roles,omitempty"` // Users with these roles are allowed + Users []string `json:"users,omitempty"` // Users with these names/patterns are allowed +} + +//======== SHARED "INGEST" CONFIG + +const ( + ModelState = "state" // Each message replaces the document + ModelTimeSeries = "time_series" // Message is added to `ts_data` key + ModelSpaceTimeSeries = "space_time_series" // Message is added to `ts_data` key w/coords + + EncodingString = "string" // Payload will be saved as a string (if it's valid UTF-8) + EncodingBase64 = "base64" // Payload will be a base64-encoded string + EncodingJSON = "JSON" // Payload interpreted as JSON +) + +type Body = map[string]any + +// Describes how to save incoming messages from a single MQTT topic. +type IngestConfig struct { + DocID string `json:"doc_id,omitempty"` // docID to write topic messages to + Scope string `json:"scope,omitempty"` // Scope to save to + Collection string `json:"collection,omitempty"` // Collection to save to + Encoding *string `json:"payload_encoding,omitempty"` // How to parse payload (default "string") + Model *string `json:"model,omitempty"` // Save mode: "state" (default) or "time_series" + StateTemplate Body `json:"state,omitempty"` // Document properties template + TimeSeries *TimeSeriesConfig `json:"time_series,omitempty"` + SpaceTimeSeries *SpaceTimeSeriesConfig `json:"space_time_series,omitempty"` + QoS *int `json:"qos,omitempty"` // QoS of subscription, client-side only (default: 2) + Channels []string `json:"channels,omitempty"` // Channel access of doc +} + +type IngestMap map[string]*IngestConfig + +const kDefaultRotationInterval = 24 * time.Hour +const kDefaultRotationMaxSize = 10_000_000 + +type TimeSeriesConfig struct { + TimeProperty string `json:"time,omitempty"` // Pattern expanding to timestamp + TimeFormat string `json:"time_format,omitempty"` // How to parse timestamp + ValuesTemplate []any `json:"values"` // Values to put in TS entry + OtherProperties Body `json:"other_properties,omitempty"` // Other properties to add to doc + Rotation string `json:"rotation,omitempty"` // Time interval to rotate docs + RotationMaxSize int `json:"rotation_max_size_bytes,omitempty"` // Size in bytes to rotate docs at + + rotationInterval time.Duration // Parsed from Rotation +} + +type SpaceTimeSeriesConfig struct { + TimeSeriesConfig + Latitude string `json:"latitude"` // Pattern expanding to latitude + Longitude string `json:"longitude"` // Pattern expanding to longitude +} + +//======== VALIDATION: + +func (config *PerDBConfig) IsEnabled() bool { + return config != nil && (config.Enabled == nil || *config.Enabled) +} + +func (config *ClientConfig) IsEnabled() bool { + return config != nil && (config.Enabled == nil || *config.Enabled) +} + +func (config *BrokerConfig) IsEnabled() bool { + return config != nil && (config.Enabled == nil || *config.Enabled) +} + +func (bc *BrokerConfig) Validate() error { + var authFilters TopicMap[*TopicConfig] + for _, tc := range bc.Allow { + if err := authFilters.AddFilter(tc.Topic, tc); err != nil { + return err + } + } + return validateSubscriptions(bc.Ingest) +} + +func (config *ClientConfig) Validate() error { + if url, _ := url.Parse(config.Broker.URL); url == nil { + return fmt.Errorf("invalid broker URL `%s`", config.Broker.URL) + } + return validateSubscriptions(config.Ingest) +} + +func (config *TimeSeriesConfig) Validate() error { + if config != nil { + // pass allowMissingProperties=true to suppress errors due to the empty payload + if _, err := applyTimeSeriesTemplate(config, []any{}, time.Unix(0, 0), true); err != nil { + return err + } else if err = config.validateRotation(); err != nil { + return err + } + } + return nil +} + +func (config *SpaceTimeSeriesConfig) Validate() error { + if config != nil { + // pass allowMissingProperties=true to suppress errors due to the empty payload + if _, err := applySpaceTimeSeriesTemplate(config, []any{}, time.Unix(0, 0), true); err != nil { + return err + } else if err = config.validateRotation(); err != nil { + return err + } + } + return nil +} + +func (config *TimeSeriesConfig) validateRotation() error { + if config.Rotation != "" { + var err error + config.rotationInterval, err = base.ParseLongDuration(config.Rotation) + if err != nil { + return err + } + } else { + config.rotationInterval = kDefaultRotationInterval + } + if config.RotationMaxSize == 0 { + config.RotationMaxSize = kDefaultRotationMaxSize + } else if config.RotationMaxSize < 1000 || config.RotationMaxSize > 20_000_000 { + return fmt.Errorf("invalid `rotation_max_size_bytes` %d", config.RotationMaxSize) + } + return nil +} + +func validateSubscriptions(subs IngestMap) error { + for topic, sub := range subs { + if sub.Scope == "" { + sub.Scope = base.DefaultScopeAndCollectionName().Scope + } + if sub.Collection == "" { + sub.Collection = base.DefaultScopeAndCollectionName().Collection + } + if !sgbucket.IsValidDataStoreName(sub.Scope, sub.Collection) { + return fmt.Errorf("invalid scope/collection names %q, %q in subscription %q", + sub.Scope, sub.Collection, topic) + } + + if _, err := MakeTopicFilter(topic); err != nil { + return err + } + if sub.QoS != nil && (*sub.QoS < 0 || *sub.QoS > 2) { + return fmt.Errorf("invalid `qos` value %v in subscription %q", *sub.QoS, topic) + } + if xform := sub.Encoding; xform != nil { + if *xform != EncodingString && *xform != EncodingBase64 && *xform != EncodingJSON { + return fmt.Errorf("invalid `transform` option %q in subscription %q", *xform, topic) + } + } + + if sub.Model != nil { + switch *sub.Model { + case ModelState: + if err := validateStateTemplate(sub.StateTemplate); err != nil { + return err + } else if sub.TimeSeries != nil || sub.SpaceTimeSeries != nil { + return fmt.Errorf("multiple model properties in subscription %q", topic) + } + case ModelTimeSeries: + if err := sub.TimeSeries.Validate(); err != nil { + return err + } else if sub.StateTemplate != nil || sub.SpaceTimeSeries != nil { + return fmt.Errorf("multiple model properties in subscription %q", topic) + } + case ModelSpaceTimeSeries: + if err := sub.SpaceTimeSeries.Validate(); err != nil { + return err + } else if sub.StateTemplate != nil || sub.TimeSeries != nil { + return fmt.Errorf("multiple model properties in subscription %q", topic) + } + default: + return fmt.Errorf("invalid `model` %q in subscription %q", *sub.Model, topic) + } + } else if sub.TimeSeries == nil && sub.StateTemplate == nil { + return fmt.Errorf("missing `model` subscription %q", topic) + } else if sub.TimeSeries != nil && sub.StateTemplate != nil { + return fmt.Errorf("cannot have both `state` and `time_series` in subscription %q", topic) + } + } + return nil +} diff --git a/mqtt/decode_packet.go b/mqtt/decode_packet.go new file mode 100644 index 0000000000..6acd86d359 --- /dev/null +++ b/mqtt/decode_packet.go @@ -0,0 +1,51 @@ +// Copyright 2024-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. + +package mqtt + +import ( + "bytes" + "fmt" + + "github.com/mochi-mqtt/server/v2/packets" +) + +// Decodes a binary MQTT packet to a `Packet` struct. +// NOTE: So far this only handles 'Publish' packets. +func decodePacket(data []byte, protocolVersion byte) (pk packets.Packet, err error) { + // This is adapted from the source code of Client.ReadPacket() in mochi-server/clients.go + in := bytes.NewBuffer(data) + b, err := in.ReadByte() + if err != nil { + return + } + + err = pk.FixedHeader.Decode(b) + if err != nil { + return + } + + pk.FixedHeader.Remaining, _, err = packets.DecodeLength(in) + if err != nil { + return + } + + pk.ProtocolVersion = protocolVersion + if pk.FixedHeader.Remaining != len(in.Bytes()) { + err = fmt.Errorf("fixedHeader.remaining disagrees with data size") + return + } + + switch pk.FixedHeader.Type { + case packets.Publish: + err = pk.PublishDecode(in.Bytes()) + default: + err = fmt.Errorf("decodePacket doesn't handle packet type %v", pk.FixedHeader.Type) + } + return +} diff --git a/mqtt/ingest.go b/mqtt/ingest.go new file mode 100644 index 0000000000..13cbed1e02 --- /dev/null +++ b/mqtt/ingest.go @@ -0,0 +1,259 @@ +// Copyright 2024-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. + +package mqtt + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "reflect" + "slices" + "time" + "unicode/utf8" + + sgbucket "github.com/couchbase/sg-bucket" + "github.com/couchbase/sync_gateway/base" + "github.com/couchbase/sync_gateway/db" +) + +type ingester struct { + ctx context.Context + topic TopicMatch + payload any + sub *IngestConfig + model string + dataStore sgbucket.DataStore + docID string + exp uint32 + timestamp time.Time +} + +// Finds an IngestConfig that matches the given topic name. +func (bc *BrokerConfig) MatchIngest(topic string) (*IngestConfig, *TopicMatch) { + bc.mutex.Lock() + defer func() { bc.mutex.Unlock() }() + + if bc.ingestFilters == nil { + if tm, err := MakeTopicMap(bc.Ingest); err == nil { + bc.ingestFilters = &tm + } else { + return nil, nil // should have been caught in validation + } + } + return bc.ingestFilters.Match(topic) +} + +// Saves an incoming MQTT message to a document in a DataStore. Returns the docID. +func IngestMessage( + ctx context.Context, + topic TopicMatch, + rawPayload []byte, + sub *IngestConfig, + dbc *db.DatabaseContext, + exp uint32, +) error { + dataStore, err := dbc.Bucket.NamedDataStore(sgbucket.DataStoreNameImpl{Scope: sub.Scope, Collection: sub.Collection}) + if err != nil { + return err + } + + ing := ingester{ + ctx: ctx, + topic: topic, + sub: sub, + dataStore: dataStore, + docID: sub.DocID, + exp: exp, + timestamp: time.Now(), + } + + // Parse the payload per the `encoding` config: + err = ing.decodePayload(rawPayload) + if err != nil { + return fmt.Errorf("failed to parse message from topic %q: %w", topic, err) + } + + if ing.docID == "" { + ing.docID = topic.Name + } else { + tmpl := templater{ + payload: nil, + timestamp: time.Time{}, + topic: topic, + } + if docID, ok := tmpl.expand(ing.docID).(string); ok { + ing.docID = docID + } else { + return fmt.Errorf("doc_id template %q expands to a non-string", ing.docID) + } + } + + // Infer model: + if sub.Model != nil { + ing.model = *sub.Model + } else if sub.StateTemplate != nil { + ing.model = ModelState + } else if sub.TimeSeries != nil { + ing.model = ModelTimeSeries + } else if sub.SpaceTimeSeries != nil { + ing.model = ModelSpaceTimeSeries + } + + switch ing.model { + case ModelState: + err = ing.saveState() + if err == nil { + base.InfofCtx(ing.ctx, base.KeyMQTT, "Saved msg as doc %q in db %s", ing.docID, ing.dataStore.GetName()) + } + case ModelTimeSeries: + var entry []any + entry, err = applyTimeSeriesTemplate(ing.sub.TimeSeries, ing.payload, ing.timestamp, false) + if err == nil { + err = ing.saveTimeSeries(entry, "ts_data", 0) + } + case ModelSpaceTimeSeries: + var entry []any + entry, err = applySpaceTimeSeriesTemplate(ing.sub.SpaceTimeSeries, ing.payload, ing.timestamp, false) + if err == nil { + err = ing.saveTimeSeries(entry, "spts_data", 1) + } + default: + err = fmt.Errorf("invalid 'model' in subscription config") // validation will have caught this + } + return err +} + +// Applies the config's encoding to a raw MQTT payload. +func (ing *ingester) decodePayload(rawPayload []byte) error { + encoding := EncodingString + if ing.sub.Encoding != nil { + encoding = *ing.sub.Encoding + } + switch encoding { + case EncodingBase64: + ing.payload = base64.StdEncoding.EncodeToString(rawPayload) + + case EncodingString: + if str := string(rawPayload); utf8.ValidString(str) { + ing.payload = str + } else { + return fmt.Errorf("invalid UTF-8") + } + + case EncodingJSON: + var j any + if err := base.JSONUnmarshal(rawPayload, &j); err == nil { + ing.payload = j + } else { + return fmt.Errorf("invalid JSON: %w", err) + } + + default: + return fmt.Errorf("invalid 'transform' in subscription config") + } + return nil +} + +// Saves a document using the "state" model. +func (ing *ingester) saveState() error { + body, err := applyStateTemplate(ing.sub.StateTemplate, ing.payload, ing.timestamp, ing.topic) + if err != nil { + return err + } else if err = ing.dataStore.Set(ing.docID, ing.exp, nil, body); err != nil { + return err + } + base.InfofCtx(ing.ctx, base.KeyMQTT, "Saved msg to doc %q in db %s", ing.docID, ing.dataStore.GetName()) + return nil +} + +// Saves a document using the "time_series" or "space_time_series" model. +func (ing *ingester) saveTimeSeries(entry []any, seriesKey string, timeStampIndex int) error { + _, err := ing.dataStore.Update(ing.docID, ing.exp, func(current []byte) (updated []byte, expiry *uint32, delete bool, err error) { + var body Body + if err := base.JSONUnmarshal(current, &body); err != nil { + body = Body{} + } + + body[seriesKey] = addToTimeSeries(body[seriesKey], entry, timeStampIndex) + + // Update start/end timestamps: + ts_new := entry[timeStampIndex].(int64) + if tsStart, ok := base.ToInt64(body["ts_start"]); !ok || ts_new < tsStart { + body["ts_start"] = ts_new + } + if tsEnd, ok := base.ToInt64(body["ts_end"]); !ok || ts_new > tsEnd { + body["ts_end"] = ts_new + } + + if ing.model == ModelSpaceTimeSeries { + // Update low/high geohashes: + sp_new := entry[0].(string) + if sp_low, ok := body["sp_low"].(string); !ok || sp_new < sp_low { + body["sp_low"] = sp_new + } + if sp_high, ok := body["sp_high"].(string); !ok || sp_new > sp_high { + body["sp_high"] = sp_new + } + } + + // Update other properties, if configured: + if props := ing.sub.TimeSeries.OtherProperties; len(props) > 0 { + props, err = applyStateTemplate(props, ing.payload, ing.timestamp, ing.topic) + if err != nil { + return + } + for k, v := range props { + if k != seriesKey && k != "ts_start" && k != "ts_end" && k != "sp_low" && k != "sp_high" { + body[k] = v + } + } + } + + updated, err = base.JSONMarshal(body) + return + }) + if err == nil { + base.InfofCtx(ing.ctx, base.KeyMQTT, "Appended msg to %s doc %q in db %s", ing.model, ing.docID, ing.dataStore.GetName()) + } + return err +} + +// Adds an entry to a (space-)time-series array, in chronological order, unless it's a dup. +func addToTimeSeries(seriesProp any, entry []any, timeStampIndex int) []any { + newTimeStamp := decodeTimestamp(entry[timeStampIndex]) + series, _ := seriesProp.([]any) + for i, item := range series { + if oldEntry, ok := item.([]any); ok && len(oldEntry) > timeStampIndex { + oldTimeStamp := decodeTimestamp(oldEntry[timeStampIndex]) + if newTimeStamp < oldTimeStamp { + // New item comes before this one: + return slices.Insert(series, i, any(entry)) + } else if newTimeStamp == oldTimeStamp && reflect.DeepEqual(entry, oldEntry) { + // It's a duplicate! + return series + } + } + } + return append(series, entry) +} + +func decodeTimestamp(n any) int64 { + switch n := n.(type) { + case int64: + return n + case float64: + return int64(n) + case json.Number: + if i, err := n.Int64(); err == nil { + return i + } + } + return 0 +} diff --git a/mqtt/modeler.go b/mqtt/modeler.go new file mode 100644 index 0000000000..1d2d88bb21 --- /dev/null +++ b/mqtt/modeler.go @@ -0,0 +1,135 @@ +// Copyright 2024-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. + +package mqtt + +import ( + "encoding/json" + "fmt" + "slices" + "time" + + "github.com/mmcloughlin/geohash" +) + +// Applies a template to a message using the "state" model, producing a document body. +func applyStateTemplate(template Body, payload any, timestamp time.Time, topic TopicMatch) (Body, error) { + if template != nil { + tmpl := templater{ + payload: payload, + timestamp: timestamp, + topic: topic} + doc, _ := tmpl.apply(template).(Body) + return doc, tmpl.err + } else { + // Default template: + return Body{"payload": payload}, nil + } +} + +// Validates a map as a template for the "state" model. +func validateStateTemplate(template Body) error { + if template != nil { + tmpl := templater{ + payload: Body{}, + timestamp: time.Now(), + allowMissingProperties: true} + tmpl.apply(template) + if tmpl.err != nil { + return tmpl.err + } + } + return nil +} + +// Applies a template to a message using the "time_series" model. +// The first item of the result array will be a timestamp. +func applyTimeSeriesTemplate(config *TimeSeriesConfig, payload any, timestamp time.Time, allowMissingProperties bool) ([]any, error) { + if config.ValuesTemplate != nil { + tmpl := templater{ + payload: payload, + timestamp: timestamp, + allowMissingProperties: allowMissingProperties} + array := tmpl.apply(config.ValuesTemplate).([]any) + if tmpl.err != nil { + return nil, tmpl.err + } + + if config.TimeProperty != "" && timestamp.Unix() != 0 { + timestamp = tmpl.expandTimestamp(config) + if tmpl.err != nil { + return nil, tmpl.err + } + if u := timestamp.Unix(); u < minValidTimestamp { + return nil, fmt.Errorf("message timestamp is too far in the past: %v", timestamp) + } else if u > maxValidTimestamp { + return nil, fmt.Errorf("message timestamp is too far in the future: %v", timestamp) + } + } + + array = slices.Insert(array, 0, any(timestamp.Unix())) + return array, nil + + } else { + // Default template: + return []any{timestamp.Unix(), payload}, nil + } +} + +// Applies a template to a message using the "time_series" model. +// The first two items of the result array will be a geohash string and a timestamp. +func applySpaceTimeSeriesTemplate(config *SpaceTimeSeriesConfig, payload any, timestamp time.Time, allowMissingProperties bool) ([]any, error) { + entry, err := applyTimeSeriesTemplate(&config.TimeSeriesConfig, payload, timestamp, allowMissingProperties) + if err == nil { + // Apply the Latitude/Longitude templates: + tmpl := templater{ + payload: payload, + timestamp: timestamp, + allowMissingProperties: allowMissingProperties} + coord := tmpl.apply([]any{config.Latitude, config.Longitude}).([]any) + if tmpl.err != nil { + return nil, tmpl.err + } else if len(coord) != 2 { + if !allowMissingProperties { + return nil, fmt.Errorf("invalid latitude or longitude values") + } + } else if lat, err := asFloat64(coord[0]); err != nil { + if !allowMissingProperties { + return nil, err + } + } else if lon, err := asFloat64(coord[1]); err != nil { + if !allowMissingProperties { + return nil, err + } + } else { + var gh any = geohash.Encode(lat, lon) + entry = slices.Insert(entry, 0, gh) + } + } + return entry, err +} + +var errNonNumeric = fmt.Errorf("value is non-numeric") + +func asFloat64(n any) (float64, error) { //TODO: Is there already a utility for this? + switch n := n.(type) { + case int64: + return float64(n), nil + case float64: + return n, nil + case json.Number: + return n.Float64() + default: + return 0, errNonNumeric + } +} + +func isNumber(n any) bool { + _, err := asFloat64(n) + return err == nil +} diff --git a/mqtt/modeler_test.go b/mqtt/modeler_test.go new file mode 100644 index 0000000000..302b8366cc --- /dev/null +++ b/mqtt/modeler_test.go @@ -0,0 +1,42 @@ +// Copyright 2024-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. + +package mqtt + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestStateTemplate(t *testing.T) { + template := Body{"temp": "${message.payload.temperature}", "time": "${date}", "foo": "bar"} + topic := TopicMatch{Name: "temp"} + + payload := Body{"temperature": 98.5} + ts, _ := time.Parse(time.RFC3339, "2024-04-11T12:41:31.016-07:00") + result, err := applyStateTemplate(template, payload, ts, topic) + + assert.NoError(t, err) + assert.Equal(t, Body{"temp": 98.5, "time": "2024-04-11T12:41:31.016-07:00", "foo": "bar"}, result) +} + +func TestTimeSeriesTemplate(t *testing.T) { + cfg := TimeSeriesConfig{ + TimeProperty: "${message.payload.when}", + ValuesTemplate: []any{"${message.payload.temperature}"}, + } + + payload := Body{"temperature": 98.5, "when": "2024-04-11T12:42:19.643-07:00"} + ts, _ := time.Parse(time.RFC3339, "2024-04-11T12:41:31.016-07:00") + + result, err := applyTimeSeriesTemplate(&cfg, payload, ts, false) + assert.NoError(t, err) + assert.EqualValues(t, []any{int64(1712864539), 98.5}, result) +} diff --git a/mqtt/persist_hook.go b/mqtt/persist_hook.go new file mode 100644 index 0000000000..a6517ba5bb --- /dev/null +++ b/mqtt/persist_hook.go @@ -0,0 +1,196 @@ +// Copyright 2024-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. + +package mqtt + +import ( + "bytes" + "errors" + + "github.com/couchbase/sync_gateway/base" + mochi "github.com/mochi-mqtt/server/v2" + "github.com/mochi-mqtt/server/v2/hooks/storage" + "github.com/mochi-mqtt/server/v2/packets" +) + +// Persistent storage hook that writes to a bucket. Implements `mqtt.Hook` +type persistHook struct { + mochi.HookBase + + persist *persister +} + +// Creates a new persistHook. +func newPersistHook(persist *persister) (*persistHook, error) { + hook := &persistHook{persist: persist} + if err := persist.initQueries(); err != nil { + return nil, err + } + return hook, nil +} + +func (h *persistHook) ID() string { + return "Bucket" +} + +var kPersistHookProvides = []byte{ + mochi.OnSessionEstablished, + mochi.OnDisconnect, + mochi.OnClientExpired, + mochi.StoredClientByID, + + mochi.OnSubscribed, + mochi.OnUnsubscribed, + + mochi.OnPublished, + mochi.OnRetainMessage, + mochi.OnRetainedExpired, + + mochi.OnQosPublish, + mochi.OnQosComplete, + mochi.OnQosDropped, +} + +func (h *persistHook) Provides(b byte) bool { + return bytes.Contains(kPersistHookProvides, []byte{b}) +} + +//======== CLIENTS: + +func isPersistentClient(cl *mochi.Client) bool { + return cl.Properties.Props.SessionExpiryInterval > 0 && !cl.Net.Inline +} + +// `OnSessionEstablished` adds a client to the store when their session is established. +func (h *persistHook) OnSessionEstablished(cl *mochi.Client, pk packets.Packet) { + defer base.FatalPanicHandler() + if isPersistentClient(cl) { + h.Log.Info("OnSessionEstablished", "id", cl.ID) + if err := h.persist.updateClient(cl); err != nil { + h.Log.Error("failed to update client data", "error", err, "session", cl.ID) + } + } else { + h.Log.Info("OnSessionEstablished -- non-persistent", "id", cl.ID) + } +} + +// `OnDisconnect` handles a client disconnection. +func (h *persistHook) OnDisconnect(cl *mochi.Client, _ error, expire bool) { + defer base.FatalPanicHandler() + if isPersistentClient(cl) { + if !expire || errors.Is(cl.StopCause(), packets.ErrSessionTakenOver) { + // Bump the session doc's "updated" field and expiration time: + h.Log.Info("OnDisconnect -- bump updated", "id", cl.ID) + if err := h.persist.disconnectClient(cl); err != nil { + h.Log.Error("failed to update disconnected client", "error", err, "session", cl.ID) + } + } else { + // ...or if the session's expired, just delete the doc now: + h.Log.Info("OnDisconnect -- expired", "id", cl.ID) + h.persist.deleteClient(cl) + } + } else { + h.Log.Info("OnDisconnect -- non-persistent", "id", cl.ID) + } +} + +// `OnClientExpired` deletes an expired client from the store. +func (h *persistHook) OnClientExpired(cl *mochi.Client) { + defer base.FatalPanicHandler() + if isPersistentClient(cl) { + h.Log.Info("OnClientExpired", "id", cl.ID) + h.persist.deleteClient(cl) + } +} + +// `StoredClientByID` looks up a persistent client by ID and username, +// returning its saved subscriptions and any inflight messages to deliver. +func (h *persistHook) StoredClientByID(id string, cl *mochi.Client) (oldRemote string, subs []storage.Subscription, msgs []storage.Message, err error) { + defer base.FatalPanicHandler() + oldRemote, subs, msgs, err = h.persist.getStoredClient(id, cl.Properties.Username) + if err == nil && oldRemote != "" { + h.Log.Info("StoredClientByID -- got client data", "id", id, "subs", len(subs), "msgs", len(msgs)) + } + return +} + +//======== SUBSCRIPTIONS: + +// `OnSubscribed` adds one or more client subscriptions to the store. +func (h *persistHook) OnSubscribed(cl *mochi.Client, pk packets.Packet, reasonCodes []byte) { + defer base.FatalPanicHandler() + if isPersistentClient(cl) { + h.Log.Info("OnSubscribed", "id", cl.ID, "num_topics", len(pk.Filters)) + if err := h.persist.updateClientSubscriptions(cl); err != nil { + h.Log.Error("failed to update client subscriptions", "error", err, "session", cl.ID) + } + } +} + +// `OnUnsubscribed` removes one or more client subscriptions from the store. +func (h *persistHook) OnUnsubscribed(cl *mochi.Client, pk packets.Packet) { + defer base.FatalPanicHandler() + if isPersistentClient(cl) && !cl.Closed() { + h.Log.Info("OnUnsubscribed", "id", cl.ID, "num_topics", len(pk.Filters)) + if err := h.persist.updateClientSubscriptions(cl); err != nil { + h.Log.Error("failed to update client subscriptions", "error", err, "session", cl.ID) + } + } +} + +//======== PUBLISHED MESSAGES: + +// `OnPublished` notifies that a message has been published to a topic. +func (h *persistHook) OnPublished(cl *mochi.Client, pk packets.Packet) { + defer base.FatalPanicHandler() + if pk.FixedHeader.Qos >= 1 { + h.persist.persistPublishedMessage(cl, pk) + } +} + +// `OnRetainMessage` adds a retained message for a topic to the store. +func (h *persistHook) OnRetainMessage(cl *mochi.Client, pk packets.Packet, result int64) { + defer base.FatalPanicHandler() + h.Log.Info("OnRetainMessage", "client", cl.ID, "topic", pk.TopicName, "result", result) + if result > 0 { + if err := h.persist.persistRetainedMessage(pk); err != nil { + h.Log.Error("failed to save retained message data", "error", err) + } + } else if result < 0 { + h.persist.deleteRetainedMessage(pk) + } +} + +//======== IN-FLIGHT (ACK) MESSAGES: + +// `OnQosPublish` adds or updates an inflight message for a client. +func (h *persistHook) OnQosPublish(cl *mochi.Client, pk packets.Packet, sent int64, resends int) { + defer base.FatalPanicHandler() + if isPersistentClient(cl) && pk.FixedHeader.Type != packets.Publish { + h.Log.Info("OnQosPublish", "client", cl.ID, "type", packets.PacketNames[pk.FixedHeader.Type]) + if err := h.persist.updateClientInflights(cl); err != nil { + h.Log.Error("failed to update client inflight messages", "error", err, "session", cl.ID) + } + } +} + +// `OnQosComplete` removes a completed message (only called for acks, apparently) +func (h *persistHook) OnQosComplete(cl *mochi.Client, pk packets.Packet) { + defer base.FatalPanicHandler() + if isPersistentClient(cl) && pk.FixedHeader.Type != packets.Publish { + h.Log.Info("OnQosComplete", "client", cl.ID, "type", packets.PacketNames[pk.FixedHeader.Type]) + if err := h.persist.updateClientInflights(cl); err != nil { + h.Log.Error("failed to update client inflight messages", "error", err, "session", cl.ID) + } + } +} + +// `OnQosDropped` removes a dropped inflight message from the store. +func (h *persistHook) OnQosDropped(cl *mochi.Client, pk packets.Packet) { + h.OnQosComplete(cl, pk) +} diff --git a/mqtt/persister.go b/mqtt/persister.go new file mode 100644 index 0000000000..f28f57a5d7 --- /dev/null +++ b/mqtt/persister.go @@ -0,0 +1,337 @@ +package mqtt + +import ( + "context" + "fmt" + "slices" + "time" + + sgbucket "github.com/couchbase/sg-bucket" + "github.com/couchbase/sync_gateway/base" + mochi "github.com/mochi-mqtt/server/v2" + "github.com/mochi-mqtt/server/v2/hooks/storage" + "github.com/mochi-mqtt/server/v2/packets" +) + +// Manages persistent MQTT state, storing it in a DataStore. Used by `persistHook`. +type persister struct { + ctx context.Context // Go context + metadataStore sgbucket.DataStore // Bucket that stores broker metadata + config *ServerConfig // Configuration +} + +//======== CLIENTS: + +// Struct used to marshal/unmarshal client session data to a bucket. +type clientDocument struct { + Username []byte `json:"user"` // Username + Remote string `json:"remote"` // Last known IP address + Clean bool `json:"clean,omitempty"` // If true, don't reuse session + Connection clientConnection `json:"conn"` // Connection state + Subscriptions []storage.Subscription `json:"subs,omitempty"` // Subscriptions + Inflights []storage.Message `json:"inflight,omitempty"` // Inflight ack messages +} + +type clientConnection struct { + Online bool `json:"online,omitempty"` // Is client still online? + UpdatedAt int64 `json:"updated,omitempty"` // Time record was last saved +} + +const kClientDocumentConnectionKey = "conn" // JSON property of clientDocument.Connection +const kClientDocumentSubscriptionsKey = "subs" // JSON property of clientDocument.Subscriptions +const kClientDocumentInflightsKey = "inflight" // JSON property of clientDocument.Inflights + +const kClientDocRefreshInterval = 5 * 60 // How often to touch exp of client session docs + +// Saves a client's persistent session data to the store. +func (p *persister) updateClient(cl *mochi.Client) error { + timestamp := cl.StopTime() + online := (timestamp <= 0) + if online { + timestamp = time.Now().Unix() + } + + doc := clientDocument{ + Username: cl.Properties.Username, + Remote: cl.Net.Remote, + Clean: cl.Properties.Clean && cl.Properties.ProtocolVersion < 5, + Connection: clientConnection{ + Online: online, + UpdatedAt: timestamp, + }, + Subscriptions: p.clientSubs(cl), + Inflights: p.clientInflights(cl), + } + + return p.metadataStore.Set(clientKey(cl), p.clientExpiry(cl, online), nil, doc) +} + +// Updates just the "subs" property of a client doc. +func (p *persister) updateClientSubscriptions(cl *mochi.Client) error { + if !isPersistentClient(cl) { + return nil + } + var jsonData []byte + if subs := p.clientSubs(cl); subs != nil { + jsonData, _ = base.JSONMarshal(subs) + } else { + jsonData = []byte("[]") + } + _, err := p.metadataStore.WriteSubDoc(p.ctx, clientKey(cl), kClientDocumentSubscriptionsKey, 0, jsonData) + return err +} + +// Updates just the "inflight" property of a client doc. +func (p *persister) updateClientInflights(cl *mochi.Client) error { + var jsonData []byte + inflights := p.clientInflights(cl) + if inflights != nil { + jsonData, _ = base.JSONMarshal(inflights) + } else { + jsonData = []byte("[]") + } + _, err := p.metadataStore.WriteSubDoc(p.ctx, clientKey(cl), kClientDocumentInflightsKey, 0, jsonData) + return err +} + +// Marks a client session as disconnected. +func (p *persister) disconnectClient(cl *mochi.Client) error { + key := clientKey(cl) + connData, _ := base.JSONMarshal(clientConnection{Online: false, UpdatedAt: cl.StopTime()}) + _, err := p.metadataStore.WriteSubDoc(p.ctx, key, kClientDocumentConnectionKey, 0, connData) + if err == nil { + _, err = p.metadataStore.Touch(key, p.clientExpiry(cl, false)) + } + return err +} + +// Deletes a client's session data. +func (p *persister) deleteClient(cl *mochi.Client) { + p.deleteDoc(clientKey(cl), "expired client data") +} + +// Finds a stored session by ID & username, returning the relevant data. +func (p *persister) getStoredClient(id string, username []byte) (oldRemote string, subs []storage.Subscription, msgs []storage.Message, err error) { + var doc clientDocument + _, err = p.metadataStore.Get(clientIDKey(id), &doc) + if err != nil { + if base.IsDocNotFoundError(err) { + err = nil + } + return + } else if !slices.Equal(doc.Username, username) { + return // Username mismatch [MQTT-5.4.2] + } else if doc.Clean { + return // [MQTT-3.1.2-4] [MQTT-3.1.4-4] + } + + oldRemote = doc.Remote + subs = doc.Subscriptions + msgs = doc.Inflights + + if len(subs) > 0 && doc.Connection.UpdatedAt > 0 { + // Find published messages created since the client went offline. + // First, construct a TopicMap for the client's subscribed topic filters: + filters := NewTopicMap[bool](len(subs)) + for _, sub := range subs { + if sub.Qos > 0 { + filters.AddFilter(sub.Filter, true) + } + } + + // Query for all stored messages saved since the client went offline: + params := map[string]any{"start_key": doc.Connection.UpdatedAt} + result, err := p.query(kViewMessagesByTime, params) + if err != nil { + base.ErrorfCtx(p.ctx, "Unexpected error querying %s view: %v", kViewMessagesByTime, err) + return "", nil, nil, err + } + for _, row := range result.Rows { + // Does the message's topic match any of the subs? + if topic, ok := row.Value.(string); ok { + if filters.Get(topic) { + var msg storage.Message + if _, err := p.metadataStore.Get(row.ID, &msg); err == nil { + base.DebugfCtx(p.ctx, base.KeyMQTT, "StoredClientByID: adding message %s", msg) + msgs = append(msgs, msg) + } + } + } + } + } + return +} + +// Returns the client's subscriptions in storeable form. +func (p *persister) clientSubs(cl *mochi.Client) (subs []storage.Subscription) { + if allSubs := cl.State.Subscriptions.GetAll(); len(allSubs) > 0 { + subs = make([]storage.Subscription, 0, len(allSubs)) + for _, pk := range allSubs { + subs = append(subs, storage.Subscription{ + Filter: pk.Filter, + Identifier: pk.Identifier, + RetainHandling: pk.RetainHandling, + Qos: pk.Qos, + RetainAsPublished: pk.RetainAsPublished, + NoLocal: pk.NoLocal, + }) + } + } + return subs +} + +// Returns a client's pending inflight (ack) packets in storeable form. +func (p *persister) clientInflights(cl *mochi.Client) (inflights []storage.Message) { + if cl.State.Inflight.Len() > 0 { + for _, pk := range cl.State.Inflight.GetAll(false) { + if pk.FixedHeader.Type != packets.Publish { + inflights = append(inflights, p.storageMessage(&pk)) + } + } + } + return +} + +// Computes expiration time of session document: +func (p *persister) clientExpiry(cl *mochi.Client, online bool) uint32 { + exp := base.Min(cl.Properties.Props.SessionExpiryInterval, p.config.MaximumSessionExpiryInterval) + if online { + exp = base.Max(exp, 2*kClientDocRefreshInterval) + } + return base.SecondsToCbsExpiry(int(exp)) +} + +//======== PUBLISHED MESSAGES: + +// Saves a published message with QoS > 0. +func (p *persister) persistPublishedMessage(cl *mochi.Client, pk packets.Packet) error { + in := p.storageMessage(&pk) + return p.metadataStore.Set(queuedMessageKey(cl, &pk), p.messageExpiry(&pk), nil, in) +} + +// Updates the 'retained' message for a topic. +func (p *persister) persistRetainedMessage(pk packets.Packet) error { + in := p.storageMessage(&pk) + return p.metadataStore.Set(retainedMessageKey(pk.TopicName), p.messageExpiry(&pk), nil, in) +} + +// Deletes the 'retained' message of a topic. +func (p *persister) deleteRetainedMessage(pk packets.Packet) { + p.deleteDoc(retainedMessageKey(pk.TopicName), "retained message data") +} + +//======== QUERIES: + +const kDesignDoc = "MQTT" // Name of the design doc, for map/reduce indexing +const kViewMessagesByTime = "msgs" + +// `initQueries` sets up indexing on the DataStore, for the below queries. +func (p *persister) initQueries() error { + viewStore, ok := p.metadataStore.(sgbucket.ViewStore) + if !ok { + return fmt.Errorf("DataStore does not support views") + } + // Get the design doc: + ddoc, err := viewStore.GetDDoc(kDesignDoc) + if err != nil { + if !base.IsDocNotFoundError(err) { + return err + } + err = nil + } + if ddoc.Views == nil { + ddoc.Views = sgbucket.ViewMap{} + } + + // Add views: + save := false + if _, ok := ddoc.Views[kViewMessagesByTime]; !ok { + const kMsgsFn = `function(doc,meta) { + if (meta.id.slice(0,15) == "_sync:mqtt:QED:") + emit(doc.created, doc.topic_name); + }` + ddoc.Views[kViewMessagesByTime] = sgbucket.ViewDef{Map: kMsgsFn} + save = true + } + + // Save any changes: + if save { + err = viewStore.PutDDoc(p.ctx, kDesignDoc, &ddoc) + } + return err +} + +// Queries a view. +func (p *persister) query(viewName string, params map[string]any) (sgbucket.ViewResult, error) { + return p.metadataStore.(sgbucket.ViewStore).View(p.ctx, kDesignDoc, viewName, params) +} + +//======== UTILITIES: + +func (p *persister) deleteDoc(key string, what string) error { + err := p.metadataStore.Delete(key) + if err != nil && !base.IsDocNotFoundError(err) { + base.ErrorfCtx(p.ctx, "MQTT: failed to delete %s %q: %v", what, key, err) + } + return err +} + +// Creates a storage.Message from a Packet. +func (p *persister) storageMessage(pk *packets.Packet) storage.Message { + return storage.Message{ + Payload: pk.Payload, + Origin: pk.Origin, + TopicName: pk.TopicName, + FixedHeader: pk.FixedHeader, + PacketID: pk.PacketID, + Created: pk.Created, + Properties: storage.MessageProperties{ + CorrelationData: pk.Properties.CorrelationData, + SubscriptionIdentifier: pk.Properties.SubscriptionIdentifier, + User: pk.Properties.User, + ContentType: pk.Properties.ContentType, + ResponseTopic: pk.Properties.ResponseTopic, + MessageExpiryInterval: pk.Properties.MessageExpiryInterval, + TopicAlias: pk.Properties.TopicAlias, + PayloadFormat: pk.Properties.PayloadFormat, + PayloadFormatFlag: pk.Properties.PayloadFormatFlag, + }, + } +} + +// Computes the expiry for a message doc. +func (p *persister) messageExpiry(pk *packets.Packet) uint32 { + exp := pk.Properties.MessageExpiryInterval + if exp == 0 { + exp = uint32(p.config.MaximumMessageExpiryInterval) + } + if sessexp := p.config.MaximumSessionExpiryInterval; sessexp < exp { + // No point keeping a message past the expiration of any session that wants it + exp = sessexp + } + return base.SecondsToCbsExpiry(int(exp)) +} + +//======== DATABASE KEYS: + +const keyPrefix = "_sync:mqtt:" // Prefix of all db keys saved by the persistHook. + +// clientKey returns a primary key for a client session. +func clientKey(cl *mochi.Client) string { + return clientIDKey(cl.ID) +} + +// clientIDKey returns a primary key for a client session, given the client ID +func clientIDKey(id string) string { + return keyPrefix + "CL:" + id +} + +// retainedMessageKey returns a primary key for a retained message in a topic. +func retainedMessageKey(topic string) string { + return keyPrefix + "RET:" + topic +} + +// queuedMessageKey returns a primary key for a queued message. +func queuedMessageKey(cl *mochi.Client, pk *packets.Packet) string { + return fmt.Sprintf("%sQED:%08x:%s", keyPrefix, pk.Created, cl.ID) +} diff --git a/mqtt/publish_hook.go b/mqtt/publish_hook.go new file mode 100644 index 0000000000..4a7f37cc7c --- /dev/null +++ b/mqtt/publish_hook.go @@ -0,0 +1,79 @@ +// Copyright 2024-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. + +package mqtt + +import ( + "context" + "fmt" + + "github.com/couchbase/sync_gateway/base" + mochi "github.com/mochi-mqtt/server/v2" + "github.com/mochi-mqtt/server/v2/packets" +) + +// Persistent storage hook that intercepts publishing messages. +type publishHook struct { + mochi.HookBase + + ctx context.Context + server *Server // My owner, the public `Server` object +} + +// Creates a new publishHook. +func newPublishHook(server *Server) (*publishHook, error) { + hook := &publishHook{ctx: server.ctx, server: server} + return hook, nil +} + +func (h *publishHook) ID() string { + return "CouchbaseBucket" +} + +func (h *publishHook) Provides(b byte) bool { + return b == mochi.OnPublish +} + +// `OnPublish` is called before a client publishes a message. +func (h *publishHook) OnPublish(client *mochi.Client, packet packets.Packet) (packets.Packet, error) { + defer base.FatalPanicHandler() + + if !client.Net.Inline { + dbc, username := h.server.clientDatabaseContext(client) + if dbc == nil { + return packet, fmt.Errorf("can't get DatabaseContext from client username %q", client.Properties.Username) + } + + // Strip the db name from the topic name: + topicName, ok := stripDbNameFromTopic(dbc, packet.TopicName) + if !ok { + base.ErrorfCtx(h.ctx, "MQTT: OnPublish received mismatched topic %q for client %q", + topicName, client.Properties.Username) + return packet, nil + } + + if config, topic := dbcSettings(dbc).MatchIngest(topicName); config != nil { + base.InfofCtx(h.ctx, base.KeyMQTT, "Ingesting message from client %q for db %q, topic %q", username, dbc.Name, topicName) + err := IngestMessage(h.ctx, *topic, packet.Payload, config, dbc, packet.Properties.MessageExpiryInterval) + if err != nil { + base.WarnfCtx(h.ctx, "MQTT broker failed to save message in db %q from topic %q: %v", dbc.Name, topicName, err) + } + } else { + base.DebugfCtx(h.ctx, base.KeyMQTT, "Client %q published non-persistent message in db %q, topic %q", username, dbc.Name, topicName) + } + + if agent := h.server.clusterAgent; agent != nil { + agent.broadcastPublish(&packet) + } + + } else { + base.DebugfCtx(h.ctx, base.KeyMQTT, "Relayed peer message to topic %q", packet.TopicName) + } + + return packet, nil +} diff --git a/mqtt/server.go b/mqtt/server.go new file mode 100644 index 0000000000..43193a6574 --- /dev/null +++ b/mqtt/server.go @@ -0,0 +1,257 @@ +// Copyright 2024-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. + +package mqtt + +import ( + "context" + "crypto/tls" + "fmt" + "strings" + + sgbucket "github.com/couchbase/sg-bucket" + "github.com/couchbase/sync_gateway/base" + "github.com/couchbase/sync_gateway/db" + mochi "github.com/mochi-mqtt/server/v2" + "github.com/mochi-mqtt/server/v2/listeners" +) + +//======== CONFIG: + +const kDefaultMaximumMessageExpiryInterval = 60 * 60 * 24 +const kDefaultMaximumSessionExpiryInterval = 60 * 60 * 24 + +// Top-level MQTT broker/server configuration. +type ServerConfig struct { + Enabled *bool `json:"enabled,omitempty"` + PublicInterface string `json:"public_interface,omitempty" help:"Network interface to bind MQTT server to"` + MetadataDB *string `json:"metadata_db,omitempty" help:"Name of database to persist MQTT state to"` + Cluster *ClusterConfig `json:"cluster,omitempty" help:"Cluster configuration (omit for no clustering)"` + MaximumMessageExpiryInterval int64 `json:"maximum_message_expiry_interval,omitempty" help:"Maximum message lifetime, in seconds; 0 means default"` + MaximumSessionExpiryInterval uint32 `json:"maximum_session_expiry_interval,omitempty" help:"Maximum disconnected session lifetime, in seconds; 0 means default"` +} + +type ClusterConfig struct { + Enabled *bool `json:"enabled,omitempty" help:"Set to false to disable clustering"` + DiscoveryAddr string `json:"discovery_address" help:"Address+port for peer discovery and gossip"` +} + +func (config *ClusterConfig) IsEnabled() bool { + return config != nil && (config.Enabled == nil || *config.Enabled) +} + +func (config *ServerConfig) IsEnabled() bool { + return config != nil && (config.Enabled == nil || *config.Enabled) +} + +func (config *ServerConfig) ParseMetadataStore() (db string, ds sgbucket.DataStoreName, err error) { + if config.MetadataDB != nil { + pieces := strings.Split(*config.MetadataDB, sgbucket.ScopeCollectionSeparator) + db = pieces[0] + scope := sgbucket.DefaultScope + collection := sgbucket.DefaultCollection + switch len(pieces) { + case 1: + case 2: + collection = pieces[1] + case 3: + scope, collection = pieces[1], pieces[2] + default: + err = fmt.Errorf("MQTT: invalid metadata store: too many components") + return + } + ds, err = sgbucket.NewValidDataStoreName(scope, collection) + } + return +} + +func (config *ServerConfig) Validate() error { + _, _, err := config.ParseMetadataStore() + return err +} + +//========== API: + +// MQTT server for Sync Gateway. +type Server struct { + ctx context.Context // Go context + config *ServerConfig // Configuration struct + delegate ServerDelegate // Maps db names to DatabaseContexts + host string // Real IP address string (no port#) + broker *mochi.Server // Mochi MQTT server + persister *persister // Manages persistent state (client sessions, messages) + clusterAgent *clusterAgent // Manages cluster communication +} + +// Interface that gives the Server access to databases. (Implemented by rest.ServerContext.) +type ServerDelegate interface { + Database(ctx context.Context, dbName string) *db.DatabaseContext +} + +// Creates a new MQTT Server instance from: +// - ctx: The context.Context +// - config: The configuration struct +// - tlsConfig: Optional TLS configuration +// - metadataStore: A DataStore for persisting non-database-specific MQTT state. +// - delegate: Maps db names to DatabaseContexts +func NewServer( + ctx context.Context, + config *ServerConfig, + tlsConfig *tls.Config, + metadataStore sgbucket.DataStore, + delegate ServerDelegate, +) (*Server, error) { + // Get a real IP address for this computer from the server address configuration: + host, err := makeRealIPAddress(config.PublicInterface, false) + if err != nil { + return nil, fmt.Errorf("couldn't look up a real IP address from %q: %w", config.PublicInterface, err) + } + + // Create the new MQTT Server. + opts := mochi.Options{ + Capabilities: mochi.NewDefaultServerCapabilities(), + InlineClient: true, + Logger: newSlogger(base.KeyMQTT, "mqtt"), + } + if config.MaximumMessageExpiryInterval <= 0 { + config.MaximumMessageExpiryInterval = kDefaultMaximumMessageExpiryInterval + } + opts.Capabilities.MaximumMessageExpiryInterval = config.MaximumMessageExpiryInterval + + if config.MaximumSessionExpiryInterval <= 0 { + config.MaximumSessionExpiryInterval = kDefaultMaximumSessionExpiryInterval + } + opts.Capabilities.MaximumSessionExpiryInterval = config.MaximumSessionExpiryInterval + + persister := &persister{ + ctx: ctx, + metadataStore: metadataStore, + config: config, + } + + server := &Server{ + ctx: ctx, + config: config, + delegate: delegate, + host: host, + broker: mochi.New(&opts), + persister: persister, + } + + // Topic renaming: + if err := server.broker.AddHook(newTopicRenameHook(server), nil); err != nil { + return nil, err + } + + // Authentication: + if err := server.broker.AddHook(newAuthHook(server), nil); err != nil { + return nil, err + } + + // Publishing: + if publishHook, err := newPublishHook(server); err != nil { + return nil, err + } else if err := server.broker.AddHook(publishHook, nil); err != nil { + return nil, err + } + + // Persistence: + if metadataStore != nil { + if persistHook, err := newPersistHook(persister); err != nil { + return nil, err + } else if err := server.broker.AddHook(persistHook, nil); err != nil { + return nil, err + } + } + + // Create a TCP listener. + listener := listeners.NewTCP(listeners.Config{ + ID: "SyncGateway", + Address: config.PublicInterface, + TLSConfig: tlsConfig, + }) + if err := server.broker.AddListener(listener); err != nil { + return nil, err + } + return server, nil +} + +// Starts the MQTT server. +func (server *Server) Start() error { + var err error + server.clusterAgent, err = startClusterAgent(server) + if err != nil { + base.ErrorfCtx(server.ctx, "MQTT: error joining cluster: %v", err) + return fmt.Errorf("error joining MQTT cluster: %w", err) + } + if err := server.broker.Serve(); err != nil { + base.ErrorfCtx(server.ctx, "MQTT: starting MQTT broker: %v", err) + server.Stop() + return fmt.Errorf("error starting MQTT broker: %w", err) + } + return nil +} + +// Stops the MQTT server. +func (server *Server) Stop() error { + if server.clusterAgent != nil { + server.clusterAgent.stop() + server.clusterAgent = nil + } + err := server.broker.Close() + server.broker = nil + return err +} + +// Splits the Client's MQTT username into database name and username at a '/'. +// If there is no '/', both will be an empty string. +func (server *Server) clientDatabaseName(client *mochi.Client) (dbName string, username string) { + dbName, username, ok := strings.Cut(string(client.Properties.Username), "/") + if !ok { + dbName = "" + username = "" + } + return +} + +// Strips the database name and a '/' from an internal topic name. +// Returns the remaining topic name, and true on success or false if it didn't have the right prefix. +func stripDbNameFromTopic(dbc *db.DatabaseContext, topicName string) (string, bool) { + prefix := dbc.Name + "/" + if strings.HasPrefix(topicName, prefix) { + return topicName[len(prefix):], true + } else { + return topicName, false + } +} + +// Looks up the DatabaseContext and username for a Client. +// DOES NOT authenticate! That's done by authHook.OnConnectAuthenticate.) +func (server *Server) clientDatabaseContext(client *mochi.Client) (*db.DatabaseContext, string) { + dbName, username := server.clientDatabaseName(client) + if dbName == "" || username == "" { + return nil, "" // wrong format + } + dbc := server.delegate.Database(server.ctx, dbName) + if dbcSettings(dbc) == nil { + dbc = nil + } + return dbc, username +} + +// Given a DatabaseContext, returns its MQTT BrokerConfig, if it exists and is enabled. +func dbcSettings(dbc *db.DatabaseContext) *BrokerConfig { + if dbc == nil { + return nil + } else if opts := dbc.Options.MQTT; opts.IsEnabled() { + if broker := opts.(*PerDBConfig).Broker; broker.IsEnabled() { + return broker + } + } + return nil +} diff --git a/mqtt/slog_adapter.go b/mqtt/slog_adapter.go new file mode 100644 index 0000000000..dd61ef3c0d --- /dev/null +++ b/mqtt/slog_adapter.go @@ -0,0 +1,135 @@ +// Copyright 2024-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. + +package mqtt + +import ( + "context" + "io" + "log" + "log/slog" + "strings" + + "github.com/couchbase/sync_gateway/base" +) + +//======== slog handler + +// Implementation of `slog.Handler` that routes `slog` messages to SG logging. +type slogHandler struct { + key base.LogKey + name string + baseAttrsStr string +} + +// Creates a new `slog.Logger` that writes to SG logging. Messages are prefixed with `name`. +func newSlogger(key base.LogKey, name string) *slog.Logger { + return slog.New(&slogHandler{key: key, name: name}) +} + +func slogToBaseLevel(level slog.Level) base.LogLevel { + if level < slog.LevelDebug { + return base.LevelTrace + } else if level < slog.LevelInfo { + return base.LevelDebug + } else if level < slog.LevelWarn { + return base.LevelInfo + } else if level < slog.LevelError { + return base.LevelWarn + } else { + return base.LevelError + } +} + +func (slh *slogHandler) Enabled(ctx context.Context, level slog.Level) bool { + switch slogToBaseLevel(level) { + case base.LevelTrace: + return base.LogTraceEnabled(ctx, base.KeyMQTT) + case base.LevelDebug: + return base.LogDebugEnabled(ctx, base.KeyMQTT) + case base.LevelInfo: + return base.LogInfoEnabled(ctx, base.KeyMQTT) + default: + return true + } +} + +func (slh *slogHandler) Handle(ctx context.Context, record slog.Record) error { + level := slogToBaseLevel(record.Level) + attrsStr := slh.baseAttrsStr + record.Attrs(func(a slog.Attr) bool { + if !a.Equal(slog.Attr{}) { + if attrsStr != "" { + attrsStr += ", " + } + attrsStr += a.String() + } + return true + }) + + base.LogLevelCtx(ctx, level, slh.key, "(%s) %s: %s", slh.name, record.Message, attrsStr) + return nil +} + +func (slh *slogHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + attrsStr := slh.baseAttrsStr + for _, attr := range attrs { + if attrsStr != "" { + attrsStr += ", " + } + attrsStr += attr.String() + } + return &slogHandler{baseAttrsStr: attrsStr} +} + +func (slh *slogHandler) WithGroup(name string) slog.Handler { + // Mochi MQTT does not appear to call WithGroup, so I'm ignoring it. + base.InfofCtx(context.Background(), base.KeyMQTT, "WithGroup: %q -- ignoring", name) + return slh +} + +//======== log.Logger adapter + +// Creates a `log.Logger` that writes to SG logs. +func newLogLogger(ctx context.Context, key base.LogKey, prefix string) *log.Logger { + return log.New(&logWriter{ctx: ctx, key: key}, prefix, 0) +} + +// Implementation of io.Writer that accepts log messages and sends them to SG logs. +type logWriter struct { + ctx context.Context + key base.LogKey +} + +func (lw *logWriter) Write(p []byte) (n int, err error) { + // (This is sort of a kludge; but the log.Logger doesn't give any metadata, + // just the line of text to log.) + message := string(p) + var level base.LogLevel = base.LevelInfo + if strings.HasPrefix(message, "[DEBUG] ") { + level = base.LevelDebug + message = message[8:] + } else if strings.HasPrefix(message, "[INFO] ") { + level = base.LevelInfo + message = message[7:] + } else if strings.HasPrefix(message, "[WARN] ") { + level = base.LevelWarn + message = message[7:] + } else if strings.HasPrefix(message, "[ERROR] ") { + level = base.LevelError + message = message[8:] + } + base.LogLevelCtx(lw.ctx, level, lw.key, "%s", message) + return len(p), nil +} + +var ( + // Enforce interface conformance: + _ slog.Handler = &slogHandler{} + _ io.Writer = &logWriter{} +) diff --git a/mqtt/templater.go b/mqtt/templater.go new file mode 100644 index 0000000000..6f77099f85 --- /dev/null +++ b/mqtt/templater.go @@ -0,0 +1,272 @@ +// Copyright 2024-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. + +package mqtt + +import ( + "encoding/json" + "fmt" + "math" + "regexp" + "strconv" + "strings" + "time" + + "github.com/couchbase/sync_gateway/base" +) + +const ( + minValidTimestamp = int64(1712329445) // April 2024, roughly + maxValidTimestamp = int64(9999999999) +) + +//TODO: Substitute `$` anywhere in a value, not just the entire value! 4/12/24 +//TODO: Support `[n]` for array indexing +//TODO: Support some type of transformer/filter, i.e. to format date from int->ISO8601 + +// Applies a template to a message +type templater struct { + payload any // The message payload; any parsed JSON type + timestamp time.Time // The time of the message + topic TopicMatch // Topic name and any matched wildcards + allowMissingProperties bool // If true, missing properties in patterns aren't an error + err error // The error, if any +} + +// Recursively applies the template +func (tmpl *templater) apply(template any) any { + if tmpl.err != nil { + return nil + } + switch val := template.(type) { + case map[string]any: + result := make(Body, len(val)) + for k, v := range val { + if a := tmpl.apply(v); a != nil { + result[k] = a + } + } + return result + case []any: + result := make([]any, 0, len(val)) + for _, v := range val { + if a := tmpl.apply(v); a != nil { + result = append(result, a) + } + } + return result + case string: + return tmpl.expand(val) + default: + return template + } +} + +var filterRE = regexp.MustCompile(`\s*\|\s*`) + +// Transforms `$`-prefixed patterns in the input string, using `base.DollarSubstitute`. +// Values are substituted from the message payload or a timestamp. +// If a pattern expands to something other than a string, there can't be anything else in the +// input string. So if the `world` property of the payload were an array, then +// "Hello ${message.payload.world}" would produce an error but "${message.payload.world}" would +// be OK and would return the array. +func (tmpl *templater) expand(input string) any { + if strings.IndexByte(input, '$') < 0 { + return input + } + + var nonStringResult any + var nonStringParam string + stringResult, err := base.DollarSubstitute(input, func(param string) (string, error) { + components := filterRE.Split(param, -1) + + result, err := tmpl.expandMatch(components[0]) + + for _, filter := range components[1:] { + if err == nil { + result, err = applyFilter(result, filter) + } + } + + if err != nil { + return "", err + } else if resultStr, ok := result.(string); ok { + return resultStr, nil + } else { + nonStringResult = result + nonStringParam = param + return "", nil + } + }) + + if nonStringParam == "" { + nonStringResult = any(stringResult) + } else if stringResult != "" { + err = fmt.Errorf("in the template %q, the parameter %q expands to a non-string", input, nonStringParam) + } + if err != nil { + tmpl.err = err + } + return nonStringResult +} + +// Given the contents of a `${...}` pattern, returns the value it resolves to. +func (tmpl *templater) expandMatch(param string) (any, error) { + matches := strings.Split(param, ".") + if len(matches) == 1 { + switch matches[0] { + case "now": + // $now defaults to numeric timestamp (Unix epoch): + return tmpl.timestamp.Unix(), nil + default: + // Is it a number? In that case, subsitute from the TopicMatch: + if n, err := strconv.ParseUint(matches[0], 10, 32); err == nil && n >= 1 { + if int(n) <= len(tmpl.topic.Wildcards) { + return tmpl.topic.Wildcards[n-1], nil + } else { + return "", fmt.Errorf("$%d is invalid: the topic filter only has %d wildcard(s)", n, len(tmpl.topic.Wildcards)) + } + } + } + + } else if matches[0] == "now" { + switch matches[1] { + case "iso8601": + // Insert ISO-8601 timestamp: + if date, err := tmpl.timestamp.MarshalText(); err == nil { + return string(date), nil + } else { + return "", err + } + case "unix": + // Insert numeric timestamp (Unix epoch): + return tmpl.timestamp.Unix(), nil + } + + } else if matches[0] == "message" { + switch matches[1] { + case "topic": + return tmpl.topic.Name, nil + case "payload": + if tmpl.payload == nil { + return nil, nil + } + root := map[string]any{"payload": tmpl.payload} + reflected, err := base.EvalKeyPath(root, strings.Join(matches[1:], "."), tmpl.allowMissingProperties) + if err != nil { + return nil, err + } else if reflected.IsValid() { + return reflected.Interface(), nil + } else { + return nil, nil + } + } + } + // Give up: + return "", fmt.Errorf("unknown template pattern ${%s}", param) +} + +// Processes an arbitrary value with a filter. +func applyFilter(v any, filter string) (any, error) { + switch filter { + case "json": // Encodes the value as a JSON string, unless it's nil + if v == nil { + return v, nil + } + j, err := base.JSONMarshal(v) + return string(j), err + case "required": // Produces an error if the value is nil + if v == nil { + return nil, fmt.Errorf("missing or invalid property") + } else { + return v, nil + } + case "number": // Converts the value to a number, parsing strings and turning true/false to 0/1 + if isNumber(v) { + return v, nil // already a number + } else if b, ok := v.(bool); ok { + if b { + return 1, nil + } else { + return 0, nil + } + } else if str, ok := v.(string); ok { + if i, err := strconv.ParseInt(str, 10, 64); err == nil { + return i, nil + } else { + return strconv.ParseFloat(str, 64) + } + } else { + return nil, nil + } + case "round": // Rounds a float to the nearest integer; leaves anything else alone. + switch n := v.(type) { + case float64: + return int64(math.Round(n)), nil + case json.Number: + if i, err := n.Int64(); err == nil { + return i, nil // leave ints alone to avoid roundoff error + } else if f, err := n.Float64(); err == nil { + return int64(math.Round(f)), nil + } + } + return v, nil + case "string": // Converts the value to a string. Like `json` except it leaves strings alone. + if _, ok := v.(string); ok { + return v, nil + } else if v == nil { + return v, nil + } else { + j, err := base.JSONMarshal(v) + return string(j), err + } + case "trim": // Trims whitespace from a string. + if str, ok := v.(string); ok { + return strings.TrimSpace(str), nil + } else { + return v, nil + } + default: + return nil, fmt.Errorf("unknown filter `|%s`", filter) + } +} + +func (tmpl *templater) expandTimestamp(config *TimeSeriesConfig) (result time.Time) { + if config.TimeProperty == "" { + return tmpl.timestamp + } + timeVal := tmpl.expand(config.TimeProperty) + if tmpl.err != nil { + return + } + if timeVal == nil { + tmpl.err = fmt.Errorf("payload is missing time property %s", config.TimeProperty) + return + } + switch config.TimeFormat { + case "iso8601", "": + if timeStr, ok := timeVal.(string); ok { + result, tmpl.err = time.Parse(time.RFC3339, timeStr) + } else { + tmpl.err = fmt.Errorf("timestamp is not a string, cannot be parsed as iso8601") + } + case "unix_epoch": + if timeInt, ok := timeVal.(int64); ok { + result = time.Unix(timeInt, 0) + } else if timeFloat, ok := timeVal.(float64); ok { + secs := math.Floor(timeFloat) + nanos := (timeFloat - secs) * 1e9 + result = time.Unix(int64(secs), int64(math.Round(nanos))) + } else { + tmpl.err = fmt.Errorf("timestamp is not a number, cannot be parsed as unix_epoch") + } + default: + tmpl.err = fmt.Errorf("invalid time-series `time_format` %q", config.TimeFormat) + } + return +} diff --git a/mqtt/templater_test.go b/mqtt/templater_test.go new file mode 100644 index 0000000000..3396e364cd --- /dev/null +++ b/mqtt/templater_test.go @@ -0,0 +1,89 @@ +// Copyright 2024-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. + +package mqtt + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTemplater(t *testing.T) { + ts, _ := time.Parse(time.RFC3339, "2024-04-11T12:41:31.016-07:00") + tmpl := templater{ + payload: Body{"temp": 98.6, "foo": "bar", "numstr": "98.6", "a": []any{10, "ten"}}, + timestamp: ts, + topic: TopicMatch{Name: "temp/ear", Wildcards: []string{"ear"}}, + } + + var result any + result = tmpl.expand("$now") + require.NoError(t, tmpl.err) + assert.Equal(t, int64(1712864491), result) + + result = tmpl.expand("${now.unix}") + require.NoError(t, tmpl.err) + assert.Equal(t, int64(1712864491), result) + + result = tmpl.expand("${now.iso8601}") + require.NoError(t, tmpl.err) + assert.Equal(t, "2024-04-11T12:41:31.016-07:00", result) + + result = tmpl.expand("${message.payload.temp}") + require.NoError(t, tmpl.err) + assert.Equal(t, 98.6, result) + + result = tmpl.expand("${message.topic}") + require.NoError(t, tmpl.err) + assert.Equal(t, "temp/ear", result) + + // Error: can't substitute a non-string into a string: + _ = tmpl.expand("${message.payload.temp} degrees") + assert.Error(t, tmpl.err) + tmpl.err = nil + + result = tmpl.expand("${message.payload.temp|string} degrees") + require.NoError(t, tmpl.err) + assert.Equal(t, "98.6 degrees", result) + + result = tmpl.expand("Temperature at $1 is ${message.payload.temp|string} degrees.") + require.NoError(t, tmpl.err) + assert.Equal(t, "Temperature at ear is 98.6 degrees.", result) + + result = tmpl.expand("${message.payload.numstr | number}") + require.NoError(t, tmpl.err) + assert.Equal(t, 98.6, result) + + result = tmpl.expand("${message.payload.numstr | number | round}") + require.NoError(t, tmpl.err) + assert.Equal(t, int64(99), result) + + result = tmpl.expand("${message.payload.a[0]}") + require.NoError(t, tmpl.err) + assert.Equal(t, 10, result) + + result = tmpl.expand("${message.payload.a[1]}") + require.NoError(t, tmpl.err) + assert.Equal(t, "ten", result) + + result = tmpl.expand("${message.payload.a |json}") + require.NoError(t, tmpl.err) + assert.Equal(t, "[10,\"ten\"]", result) + + result = tmpl.expand("${message.payload.missing}") + require.NoError(t, tmpl.err) + assert.Nil(t, result) + + // Error: nil value with "required" filter + _ = tmpl.expand("${message.payload.missing | required}") + assert.Error(t, tmpl.err) + tmpl.err = nil +} diff --git a/mqtt/topic_rename_hook.go b/mqtt/topic_rename_hook.go new file mode 100644 index 0000000000..f5c0d99330 --- /dev/null +++ b/mqtt/topic_rename_hook.go @@ -0,0 +1,88 @@ +// Copyright 2024-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. + +package mqtt + +import ( + "context" + "strings" + + "github.com/couchbase/sync_gateway/base" + mochi "github.com/mochi-mqtt/server/v2" + "github.com/mochi-mqtt/server/v2/packets" +) + +// Hook that renames topics so that internally they are prefixed with the database name. +type topicRenameHook struct { + mochi.HookBase + ctx context.Context + server *Server +} + +func newTopicRenameHook(server *Server) *topicRenameHook { + return &topicRenameHook{ctx: server.ctx, server: server} +} + +func (h *topicRenameHook) ID() string { + return "TopicRenameHook" +} + +// `Provides` indicates which hook methods this hook provides. +func (h *topicRenameHook) Provides(b byte) bool { + return b == mochi.OnPacketRead || b == mochi.OnPacketEncode +} + +// `OnPacketRead` is called after an MQTT packet is received and parsed, but before it's handled. +func (h *topicRenameHook) OnPacketRead(client *mochi.Client, packet packets.Packet) (packets.Packet, error) { + defer base.FatalPanicHandler() + if prefix, _ := h.server.clientDatabaseName(client); prefix != "" { + prefix += "/" + fix := func(name *string) { + if *name != "" && (*name)[0] != '$' { + base.DebugfCtx(h.ctx, base.KeyMQTT, "Incoming packet: renamed %q -> %q", + *name, prefix+*name) + *name = prefix + *name + } + } + + fix(&packet.TopicName) + fix(&packet.Properties.ResponseTopic) + fix(&packet.Connect.WillTopic) + fix(&packet.Connect.WillProperties.ResponseTopic) + for i := 0; i < len(packet.Filters); i++ { + fix(&packet.Filters[i].Filter) + } + } + return packet, nil +} + +// `OnPacketEncode` is called just before a Packet struct is encoded to MQTT and sent to a client. +func (h *topicRenameHook) OnPacketEncode(client *mochi.Client, packet packets.Packet) packets.Packet { + defer base.FatalPanicHandler() + if prefix, _ := h.server.clientDatabaseName(client); prefix != "" { + prefix += "/" + fix := func(name *string) { + if strings.HasPrefix(*name, prefix) { + base.DebugfCtx(h.ctx, base.KeyMQTT, "Outgoing packet: renamed %q -> %q", + *name, (*name)[len(prefix):]) + *name = (*name)[len(prefix):] + } else if *name != "" && (*name)[0] != '$' { + base.WarnfCtx(h.ctx, "Unprefixed topic in outgoing packet: %q", *name) + } + } + + fix(&packet.TopicName) + fix(&packet.Properties.ResponseTopic) + fix(&packet.Connect.WillTopic) // unnecessary? + fix(&packet.Connect.WillProperties.ResponseTopic) // unnecessary? + for i := 0; i < len(packet.Filters); i++ { + fix(&packet.Filters[i].Filter) + } + } + return packet +} diff --git a/mqtt/topicfilter.go b/mqtt/topicfilter.go new file mode 100644 index 0000000000..7065f63400 --- /dev/null +++ b/mqtt/topicfilter.go @@ -0,0 +1,171 @@ +// Copyright 2024-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. + +package mqtt + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +var badPlusRe = regexp.MustCompile(`[^/]\+|\+[^/]`) // matches `+` next to a non-`/`` + +// An MQTT topic filter -- a topic string that may contain wildcards `+` and `#`. +type TopicFilter struct { + pattern *regexp.Regexp // Equivalent RE, or nil if filter contains no wildcards + literal string // Filter itself, if it has no wildcards, else empty + value any // Used by TopicMap to store associated values on its filters +} + +// Compiles an MQTT topic filter string. +func MakeTopicFilter(topicPattern string) (filter TopicFilter, err error) { + err = filter.Init(topicPattern) + return +} + +func (filter *TopicFilter) Init(topicPattern string) error { + if !strings.ContainsAny(topicPattern, "+#") { + filter.literal = topicPattern + return nil + } + + if badPlusRe.MatchString(topicPattern) { + return fmt.Errorf("invalid use of `+` wildcard in topic pattern %q", topicPattern) + } + + pat := regexp.QuoteMeta(topicPattern) + pat = strings.ReplaceAll(pat, `\+`, `([^/]+)`) + + if pat == "#" { + pat = "(.*)" + } else if strings.HasSuffix(pat, "/#") { + pat = pat[0:len(pat)-2] + `(?:/(.+))?` + } else if strings.Contains(pat, "#") { + return fmt.Errorf("invalid use of `#` wildcard in topic pattern %q", topicPattern) + } + + re, err := regexp.Compile(`^` + pat + `$`) + filter.pattern = re + return err +} + +// Returns true if this filter matches the topic. +func (tf *TopicFilter) Matches(topic string) bool { + if tf.pattern != nil { + return tf.pattern.MatchString(topic) + } else { + return tf.literal == topic + } +} + +// If this filter matches the topic, returns an array of submatch strings, one for each wildcard +// (`+` or `#`) in the filter. If the filter has no wildcards, the array will be empty. +// If the filter doesn't match, returns nil. +func (tf *TopicFilter) FindStringSubmatch(topic string) []string { + if tf.pattern != nil { + if match := tf.pattern.FindStringSubmatch(topic); match != nil { + return match[1:] + } + } else { + if tf.literal == topic { + return []string{} + } + } + return nil +} + +//======== TOPIC MAP: + +// A mapping from MQTT topic filters to arbitrary values. +type TopicMap[V any] struct { + filters []TopicFilter +} + +// Creates an empty TopicMap, preallocating space for `capacity` filters. +func NewTopicMap[V any](capacity int) TopicMap[V] { + return TopicMap[V]{filters: make([]TopicFilter, 0, capacity)} +} + +// Creates a TopicMap from a map, interpreting the keys as topic filters. +func MakeTopicMap[V any](m map[string]V) (tm TopicMap[V], err error) { + for pattern, val := range m { + if err = tm.AddFilter(pattern, val); err != nil { + return + } + } + return +} + +// Adds a single topic filter and value. +func (tm *TopicMap[V]) AddFilter(pattern string, val V) error { + f, err := MakeTopicFilter(pattern) + if err == nil { + f.value = val + tm.filters = append(tm.filters, f) + } + return err +} + +// Matches a topic name to a filter and returns the associated value. +// The first filter that matches is used. +func (tm *TopicMap[V]) Get(topic string) (val V) { + for _, tf := range tm.filters { + if tf.Matches(topic) { + return tf.value.(V) + } + } + return +} + +// Matches a topic name to a filter and returns the associated value, +// plus the strings matched by any wildcards. +// The first filter that matches is used. +func (tm *TopicMap[V]) Match(topic string) (val V, match *TopicMatch) { + for _, tf := range tm.filters { + if matches := tf.FindStringSubmatch(topic); matches != nil { + return tf.value.(V), &TopicMatch{Name: topic, Wildcards: matches} + } + } + return +} + +//======== TOPICMATCH: + +// The result of successfully matching a topic against a TopicMap. +type TopicMatch struct { + Name string // The matched topic name + Wildcards []string // Values for the wildcards in the filter string +} + +var kTemplateTopicMatchRegexp = regexp.MustCompile(`^\$(\d+)$`) + +// If `pattern` is of the form `$n`, and `n` is a valid 1-based index into the array of +// wildcards, returns the corresponding wildcard match. +func (tm *TopicMatch) ExpandPattern(pattern string) (string, error) { + if !strings.HasPrefix(pattern, "$") { + return pattern, nil + } + if match := kTemplateTopicMatchRegexp.FindStringSubmatch(pattern); match != nil { + return tm.ExpandNumberPattern(match[1]) + } + return "", fmt.Errorf("invalid topic wildcard substitution string %q", pattern) +} + +// If `pattern` is of the form `$n`, and `n` is a valid 1-based index into the array of +// wildcards, returns the corresponding wildcard match. +func (tm *TopicMatch) ExpandNumberPattern(pattern string) (string, error) { + if n, err := strconv.ParseUint(pattern, 10, 32); err != nil || n < 1 { + return "", fmt.Errorf("invalid topic wildcard substitution string $%s", pattern) + } else if int(n) > len(tm.Wildcards) { + return "", fmt.Errorf("$%d is invalid: the topic filter only has %d wildcard(s)", n, len(tm.Wildcards)) + } else { + return tm.Wildcards[n-1], nil + } +} diff --git a/mqtt/topicfilter_test.go b/mqtt/topicfilter_test.go new file mode 100644 index 0000000000..1dbba3783a --- /dev/null +++ b/mqtt/topicfilter_test.go @@ -0,0 +1,110 @@ +// Copyright 2024-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. + +package mqtt + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTopicFilterWithoutWildcards(t *testing.T) { + r, err := MakeTopicFilter("fo.oo/bar") + assert.NoError(t, err) + assert.True(t, r.Matches("fo.oo/bar")) + assert.False(t, r.Matches("foxoo/bar")) + assert.False(t, r.Matches("fo.oo")) + assert.False(t, r.Matches("xxfo.oo/barxx")) + assert.Equal(t, []string{}, r.FindStringSubmatch("fo.oo/bar")) + assert.Equal(t, []string(nil), r.FindStringSubmatch("fooo/ba")) +} + +func TestTopicFilterWithPlus(t *testing.T) { + r, err := MakeTopicFilter("+/bar") + assert.NoError(t, err) + assert.True(t, r.Matches("something/bar")) + assert.False(t, r.Matches("bar")) + assert.False(t, r.Matches("/bar")) + assert.False(t, r.Matches("/something/bar")) + assert.False(t, r.Matches("/something/else/bar")) + assert.Equal(t, []string{"something"}, r.FindStringSubmatch("something/bar")) + + r, err = MakeTopicFilter("foo/+") + assert.NoError(t, err) + assert.True(t, r.Matches("foo/bar")) + assert.False(t, r.Matches("foo")) + assert.False(t, r.Matches("foo/bar/baz")) + assert.Equal(t, []string{"bar"}, r.FindStringSubmatch("foo/bar")) + + r, err = MakeTopicFilter("fo.oo/+/bar") + assert.NoError(t, err) + assert.True(t, r.Matches("fo.oo/something/bar")) + assert.False(t, r.Matches("fo.oo/bar")) + assert.False(t, r.Matches("fo.oo//bar")) + assert.False(t, r.Matches("fo.oo/something/else/bar")) + assert.Equal(t, []string{"something"}, r.FindStringSubmatch("fo.oo/something/bar")) +} + +func TestTopicFilterWithOctothorpe(t *testing.T) { + r, err := MakeTopicFilter("fo.oo/bar/#") + assert.NoError(t, err) + assert.True(t, r.Matches("fo.oo/bar")) + assert.True(t, r.Matches("fo.oo/bar/baz/yow")) + assert.False(t, r.Matches("fo.oo/barbecue")) + assert.Equal(t, []string{"baz/yow"}, r.FindStringSubmatch("fo.oo/bar/baz/yow")) +} + +func TestTopicFilterWithMultipleWildcards(t *testing.T) { + r, err := MakeTopicFilter("+/foo/+/bar/#") + assert.NoError(t, err) + assert.True(t, r.Matches("a/foo/b/bar")) + assert.True(t, r.Matches("a/foo/b/bar/c/d")) + assert.False(t, r.Matches("a/foo/bar/b")) + assert.Equal(t, []string{"a", "b", "c/d"}, r.FindStringSubmatch("a/foo/b/bar/c/d")) + assert.Equal(t, []string(nil), r.FindStringSubmatch("foo/b/bar/c/d")) +} + +func TestTopicFilterAllTopics(t *testing.T) { + r, err := MakeTopicFilter("#") + assert.NoError(t, err) + assert.True(t, r.Matches("a/foo/b/bar")) + assert.True(t, r.Matches("z")) + assert.True(t, r.Matches("b/c")) + assert.Equal(t, []string{"a/foo/b/bar/c/d"}, r.FindStringSubmatch("a/foo/b/bar/c/d")) +} + +func TestTopicFilterBadPattern(t *testing.T) { + _, err := MakeTopicFilter("foo/pl+us/bar") + assert.Error(t, err) + _, err = MakeTopicFilter("foo/++/bar") + assert.Error(t, err) + _, err = MakeTopicFilter("foo/#/bar") + assert.Error(t, err) + _, err = MakeTopicFilter("foo/bar#") + assert.Error(t, err) + _, err = MakeTopicFilter("foo/bar/#/") + assert.Error(t, err) +} + +func TestTopicMap(t *testing.T) { + input := map[string]int{ + "foo/bar": 1, + "temp/#": 2, + "user/+/weight": 3, + } + topics, err := MakeTopicMap(input) + assert.NoError(t, err) + + assert.Equal(t, topics.Get("foo/bar"), 1) + assert.Equal(t, topics.Get("foo/barrrr"), 0) + assert.Equal(t, topics.Get("temp"), 2) + assert.Equal(t, topics.Get("temp/outdoor/garden"), 2) + assert.Equal(t, topics.Get("user/jens/weight"), 3) + assert.Equal(t, topics.Get("user/jens/weight/kg"), 0) +} diff --git a/mqtt/utils.go b/mqtt/utils.go new file mode 100644 index 0000000000..02c6d2a7a1 --- /dev/null +++ b/mqtt/utils.go @@ -0,0 +1,76 @@ +// Copyright 2024-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. + +package mqtt + +import ( + "fmt" + "net" +) + +// Returns a real IPv4 or IPv6 address for this computer. +func getIPAddress(ipv6 bool) (string, error) { + interfaces, err := net.Interfaces() + if err != nil { + return "", err + } + for _, i := range interfaces { + // The interface must be up and running, but not loopback nor point-to-point: + if (i.Flags&net.FlagUp != 0) && (i.Flags&net.FlagRunning != 0) && (i.Flags&net.FlagLoopback == 0) && (i.Flags&net.FlagPointToPoint == 0) { + if addrs, err := i.Addrs(); err == nil { + for _, addr := range addrs { + if ip, _, err := net.ParseCIDR(addr.String()); err == nil { + // The address must not be loopback, multicast, nor link-local: + if !ip.IsLoopback() && !ip.IsLinkLocalMulticast() && !ip.IsLinkLocalUnicast() && !ip.IsMulticast() && !ip.IsUnspecified() { + //log.Printf("%s: [%s] %v", i.Name, i.Flags.String(), ip) + // If it matches the IP version, return it: + if (ip.To4() == nil) == ipv6 { + return ip.String(), nil + } + } + } + } + } + } + } + return "", nil +} + +// Takes an address or address-and-port string, checks whether it contains an "unspecified" +// IP address ("0.0.0.0" or "::"), and if so replaces it with a real IPv4 or IPv6 address. +// If `keepPort` is false, any port number will be removed. +func makeRealIPAddress(addrStr string, keepPort bool) (string, error) { + host, port, err := net.SplitHostPort(addrStr) + if err != nil { + err = nil + host = addrStr + } + + var addr net.IP + if host != "" { + if addr = net.ParseIP(host); addr == nil { + return "", fmt.Errorf("%q is not a valid IP address", addrStr) + } + } + + if addr == nil || addr.IsUnspecified() { + // The address is "0.0.0.0" or "::"; find a real address of the same type: + ipv6 := addr != nil && (addr.To4() == nil) + if host, err = getIPAddress(ipv6); err != nil { + return "", err + } else if keepPort && port != "" { + host = net.JoinHostPort(host, port) + } + return host, nil + } else if keepPort { + return addrStr, nil + } else { + return host, nil + } + +} diff --git a/mqtt/utils_test.go b/mqtt/utils_test.go new file mode 100644 index 0000000000..deaac4460c --- /dev/null +++ b/mqtt/utils_test.go @@ -0,0 +1,90 @@ +// Copyright 2024-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. + +package mqtt + +import ( + "log" + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetMyAddress(t *testing.T) { + addr, err := getIPAddress(false) + require.NoError(t, err) + require.NotEmpty(t, addr) + log.Printf("IPv4 = %s", addr) + + addr, err = getIPAddress(true) + require.NoError(t, err) + require.NotEmpty(t, addr) + log.Printf("IPv6 = %s", addr) +} + +func TestGetRealIPAddress(t *testing.T) { + + getReal := func(addr string) (host string, port string) { + addr, err := makeRealIPAddress(addr, true) + require.NoError(t, err) + if host, port, err = net.SplitHostPort(addr); err == nil { + return host, port + } else { + return addr, "" + } + } + + // Unspecified addresses will be replaced with real ones: + + host, port := getReal(":12345") + assert.NotEqual(t, "", host) + assert.Equal(t, "12345", port) + + host, port = getReal("0.0.0.0") + log.Printf("IPv4 = %s", host) + assert.NotEqual(t, "0.0.0.0", host) + assert.Empty(t, port) + + host, port = getReal("0.0.0.0:12345") + assert.NotEqual(t, "0.0.0.0", host) + assert.Equal(t, "12345", port) + + // Real addresses won't change: + + host, port = getReal("17.18.19.20") + assert.Equal(t, "17.18.19.20", host) + assert.Empty(t, port) + + host, port = getReal("17.18.19.20:12345") + assert.Equal(t, "17.18.19.20", host) + assert.Equal(t, "12345", port) + + // IPv6, unspecified addresses: + + host, port = getReal("::") + log.Printf("IPv4 = %s", host) + assert.NotEqual(t, "::", host) + assert.Empty(t, port) + + host, port = getReal("[::]:12345") + log.Printf("IPv4 = %s", host) + assert.NotEqual(t, "::", host) + assert.Equal(t, "12345", port) + + // IPv6, real addresses: + + host, port = getReal("2001:db8::1") + assert.Equal(t, "2001:db8::1", host) + assert.Empty(t, port) + + host, port = getReal("[2001:db8::1]:12345") + assert.Equal(t, "2001:db8::1", host) + assert.Equal(t, "12345", port) +} diff --git a/rest/config.go b/rest/config.go index 90fdb2f421..133137b145 100644 --- a/rest/config.go +++ b/rest/config.go @@ -37,6 +37,7 @@ import ( "github.com/couchbase/sync_gateway/base" "github.com/couchbase/sync_gateway/db" "github.com/couchbase/sync_gateway/db/functions" + "github.com/couchbase/sync_gateway/mqtt" "github.com/couchbaselabs/rosmar" ) @@ -188,6 +189,7 @@ type DbConfig struct { Guest *auth.PrincipalConfig `json:"guest,omitempty"` // Guest user settings JavascriptTimeoutSecs *uint32 `json:"javascript_timeout_secs,omitempty"` // The amount of seconds a Javascript function can run for. Set to 0 for no timeout. UserFunctions *functions.FunctionsConfig `json:"functions,omitempty"` // Named JS fns for clients to call + MQTT *mqtt.PerDBConfig `json:"mqtt,omitempty"` // MQTT client connection & subscriptions Suspendable *bool `json:"suspendable,omitempty"` // Allow the database to be suspended ChangesRequestPlus *bool `json:"changes_request_plus,omitempty"` // If set, is used as the default value of request_plus for non-continuous replications CORS *auth.CORSConfig `json:"cors,omitempty"` // Per-database CORS config @@ -997,6 +999,19 @@ func (dbConfig *DbConfig) validateVersion(ctx context.Context, isEnterpriseEditi } } + if dbConfig.MQTT != nil { + for _, client := range dbConfig.MQTT.Clients { + if err := client.Validate(); err != nil { + multiError = multiError.Append(fmt.Errorf("invalid MQTT client config: %w", err)) + } + } + if dbConfig.MQTT.Broker != nil { + if err := dbConfig.MQTT.Broker.Validate(); err != nil { + multiError = multiError.Append(fmt.Errorf("invalid MQTT broker config: %w", err)) + } + } + } + if dbConfig.CORS != nil { // these values will likely to be ignored by the CORS handler unless browser sends abornmal Origin headers _, err := hostOnlyCORS(dbConfig.CORS.Origin) @@ -1387,6 +1402,12 @@ func (sc *StartupConfig) Validate(ctx context.Context, isEnterpriseEdition bool) } } + if config := sc.API.MQTT; config != nil { + if err := config.Validate(); err != nil { + multiError = multiError.Append(err) + } + } + return multiError.ErrorOrNil() } @@ -1955,6 +1976,17 @@ func StartServer(ctx context.Context, config *StartupConfig, sc *ServerContext) } else { base.ConsolefCtx(ctx, base.LevelInfo, base.KeyAll, "Diagnostic API not enabled - skipping.") } + + if config.API.MQTT != nil { + if sc.Config.Unsupported.MQTT != nil && *sc.Config.Unsupported.MQTT { + if err := sc.StartMQTTServer(ctx, config.API.MQTT, &config.API.HTTPS); err != nil { + base.ErrorfCtx(ctx, "Error serving MQTT: %v", err) + } + } else { + base.WarnfCtx(ctx, `Server config option "api.mqtt_broker" ignored because unsupported.mqtt feature flag is not enabled`) + } + } + base.ConsolefCtx(ctx, base.LevelInfo, base.KeyAll, "Starting metrics server on %s", config.API.MetricsInterface) go func() { if err := sc.Serve(ctx, config, metricsServer, config.API.MetricsInterface, CreateMetricHandler(sc)); err != nil { diff --git a/rest/config_legacy.go b/rest/config_legacy.go index 71a8cad27c..b0d9c7e3a9 100644 --- a/rest/config_legacy.go +++ b/rest/config_legacy.go @@ -21,6 +21,7 @@ import ( "github.com/couchbase/gocbcore/v10/connstr" "github.com/couchbase/sync_gateway/auth" "github.com/couchbase/sync_gateway/base" + "github.com/couchbase/sync_gateway/mqtt" pkgerrors "github.com/pkg/errors" ) @@ -67,6 +68,7 @@ type LegacyServerConfig struct { MetricsInterfaceAuthentication *bool `json:"metrics_interface_authentication,omitempty" help:"Whether the metrics API requires authentication"` EnableAdminAuthenticationPermissionsCheck *bool `json:"enable_advanced_auth_dp,omitempty" help:"Whether to enable the permissions check feature of admin auth"` ConfigUpgradeGroupID string `json:"config_upgrade_group_id,omitempty"` // If set, determines the config group ID used when this legacy config is upgraded to a persistent config. + MQTT *mqtt.ServerConfig `json:"mqtt_broker,omitempty"` RemovedLegacyServerConfig } @@ -106,6 +108,7 @@ type UnsupportedServerConfigLegacy struct { Http2Config *HTTP2Config `json:"http2,omitempty"` // Config settings for HTTP2 StatsLogFrequencySecs *uint `json:"stats_log_freq_secs,omitempty"` // How often should stats be written to stats logs UseStdlibJSON *bool `json:"use_stdlib_json,omitempty"` // Bypass the jsoniter package and use Go's stdlib instead + MQTT *bool `json:"mqtt,omitempty"` // MQTT support } // ToStartupConfig returns the given LegacyServerConfig as a StartupConfig and a set of DBConfigs. @@ -275,6 +278,10 @@ func (lc *LegacyServerConfig) ToStartupConfig(ctx context.Context) (*StartupConf if lc.SSLKey != nil { sc.API.HTTPS.TLSKeyPath = *lc.SSLKey } + if lc.MQTT != nil { + sc.API.MQTT = lc.MQTT + sc.Unsupported.MQTT = lc.Unsupported.MQTT + } if lc.Unsupported != nil { if lc.Unsupported.StatsLogFrequencySecs != nil { sc.Unsupported.StatsLogFrequency = base.NewConfigDuration(time.Second * time.Duration(*lc.Unsupported.StatsLogFrequencySecs)) diff --git a/rest/config_startup.go b/rest/config_startup.go index 2952a55099..c872cda949 100644 --- a/rest/config_startup.go +++ b/rest/config_startup.go @@ -17,6 +17,7 @@ import ( "github.com/couchbase/sync_gateway/auth" "github.com/couchbase/sync_gateway/base" "github.com/couchbase/sync_gateway/db" + "github.com/couchbase/sync_gateway/mqtt" ) const ( @@ -130,6 +131,8 @@ type APIConfig struct { HTTPS HTTPSConfig `json:"https,omitempty"` CORS *auth.CORSConfig `json:"cors,omitempty"` + + MQTT *mqtt.ServerConfig `json:"mqtt_broker,omitempty"` } type HTTPSConfig struct { @@ -159,6 +162,7 @@ type UnsupportedConfig struct { UseXattrConfig *bool `json:"use_xattr_config,omitempty" help:"Store database configurations in system xattrs"` AllowDbConfigEnvVars *bool `json:"allow_dbconfig_env_vars,omitempty" help:"Can be set to false to skip environment variable expansion in database configs"` DiagnosticInterface string `json:"diagnostic_interface,omitempty" help:"Network interface to bind diagnostic API to"` + MQTT *bool `json:"mqtt" help:"Enable MQTT server or clients"` } type ServerlessConfig struct { @@ -226,6 +230,7 @@ func NewEmptyStartupConfig() StartupConfig { return StartupConfig{ API: APIConfig{ CORS: &auth.CORSConfig{}, + MQTT: &mqtt.ServerConfig{}, }, Logging: base.LoggingConfig{ Console: &base.ConsoleLoggerConfig{}, diff --git a/rest/mqtt_server.go b/rest/mqtt_server.go new file mode 100644 index 0000000000..faa72f2953 --- /dev/null +++ b/rest/mqtt_server.go @@ -0,0 +1,58 @@ +// Copyright 2024-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. + +package rest + +import ( + "context" + "fmt" + + sgbucket "github.com/couchbase/sg-bucket" + "github.com/couchbase/sync_gateway/base" + "github.com/couchbase/sync_gateway/mqtt" +) + +// Starts MQTT server. Called by StartServer. +func (sc *ServerContext) StartMQTTServer(ctx context.Context, config *mqtt.ServerConfig, httpsConfig *HTTPSConfig) error { + if !config.IsEnabled() { + return nil + } + + base.ConsolefCtx(ctx, base.LevelInfo, base.KeyAll, "Starting MQTT server on %s", config.PublicInterface) + // TLS config: + tlsMinVersion := GetTLSVersionFromString(&httpsConfig.TLSMinimumVersion) + tlsConfig, err := base.MakeTLSConfig(httpsConfig.TLSCertPath, httpsConfig.TLSKeyPath, tlsMinVersion) + if err != nil { + return err + } + + // Metadata store: + var metadataStore sgbucket.DataStore + if config.MetadataDB != nil { + dbName, dsName, err := config.ParseMetadataStore() + if err != nil { + return err + } + db := sc.Database(ctx, dbName) + if db == nil { + return fmt.Errorf("MQTT: invalid metadata store: database %q does not exist", dbName) + } + metadataStore, err = db.Bucket.NamedDataStore(dsName) + if err != nil { + return fmt.Errorf("MQTT: invalid metadata store: %w", err) + } + } + + server, err := mqtt.NewServer(ctx, config, tlsConfig, metadataStore, sc) + if err != nil { + return err + } + sc.mqttServer = server + go server.Start() + return nil +} diff --git a/rest/server_context.go b/rest/server_context.go index c1c6de31be..aa3ef014f3 100644 --- a/rest/server_context.go +++ b/rest/server_context.go @@ -30,6 +30,7 @@ import ( "github.com/couchbase/sync_gateway/auth" "github.com/couchbase/sync_gateway/db/functions" + "github.com/couchbase/sync_gateway/mqtt" "github.com/couchbase/gocbcore/v10" sgbucket "github.com/couchbase/sg-bucket" @@ -76,6 +77,7 @@ type ServerContext struct { cpuPprofFileMutex sync.Mutex // Protect cpuPprofFile from concurrent Start and Stop CPU profiling requests cpuPprofFile *os.File // An open file descriptor holds the reference during CPU profiling _httpServers map[serverType]*serverInfo // A list of HTTP servers running under the ServerContext + mqttServer *mqtt.Server // MQTT server GoCBAgent *gocbcore.Agent // GoCB Agent to use when obtaining management endpoints NoX509HTTPClient *http.Client // httpClient for the cluster that doesn't include x509 credentials, even if they are configured for the cluster hasStarted chan struct{} // A channel that is closed via PostStartup once the ServerContext has fully started @@ -238,6 +240,12 @@ func (sc *ServerContext) Close(ctx context.Context) { sc.databases_ = nil sc.invalidDatabaseConfigTracking.dbNames = nil + if sc.mqttServer != nil { + if err := sc.mqttServer.Stop(); err != nil { + base.WarnfCtx(ctx, "Error closing MQTT server: %v", err) + } + } + for _, s := range sc._httpServers { base.InfofCtx(ctx, base.KeyHTTP, "Closing HTTP Server: %v", s.addr) if err := s.server.Close(); err != nil { @@ -1264,6 +1272,7 @@ func dbcOptionsFromConfig(ctx context.Context, sc *ServerContext, config *DbConf Serverless: sc.Config.IsServerless(), ChangesRequestPlus: base.BoolDefault(config.ChangesRequestPlus, false), // UserFunctions: config.UserFunctions, // behind feature flag (see below) + // MQTT: config.MQTT, // behind feature flag (see below) MaxConcurrentChangesBatches: sc.Config.Replicator.MaxConcurrentChangesBatches, MaxConcurrentRevs: sc.Config.Replicator.MaxConcurrentRevs, } @@ -1283,6 +1292,17 @@ func dbcOptionsFromConfig(ctx context.Context, sc *ServerContext, config *DbConf base.WarnfCtx(ctx, `Database config option "functions" ignored because unsupported.user_queries feature flag is not enabled`) } + if config.MQTT != nil { + if sc.Config.Unsupported.MQTT != nil && *sc.Config.Unsupported.MQTT { + contextOptions.MQTT = config.MQTT + if config.MQTT.Broker != nil && !sc.Config.API.MQTT.IsEnabled() { + base.WarnfCtx(ctx, `Database config option "mqtt.broker" ignored because server's MQTT broker is not enabled`) + } + } else { + base.WarnfCtx(ctx, `Database config option "mqtt" ignored because unsupported.mqtt feature flag is not enabled`) + } + } + return contextOptions, nil } From 714627ad9bd1be5b4a7de5c939fedc1e2b2e1ed9 Mon Sep 17 00:00:00 2001 From: Jens Alfke Date: Fri, 17 May 2024 13:32:04 -0700 Subject: [PATCH 2/8] go.mod: Replace mochi-mqtt with our own fork for now --- go.mod | 3 +++ go.sum | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 868743c078..939f5f5968 100644 --- a/go.mod +++ b/go.mod @@ -44,6 +44,9 @@ require ( gopkg.in/square/go-jose.v2 v2.6.0 ) +// Temporarily substitute our own vendored fork of mochi-mqtt/server until PRs are merged --jens +replace github.com/mochi-mqtt/server/v2 => github.com/couchbasedeps/mochi-mqtt-server/v2 v2.0.0-20240515222400-b57f42aef8ae + require ( github.com/BurntSushi/toml v1.3.2 // indirect github.com/armon/go-metrics v0.4.1 // indirect diff --git a/go.sum b/go.sum index ee6a5d1afc..c505d1e512 100644 --- a/go.sum +++ b/go.sum @@ -81,6 +81,8 @@ github.com/couchbase/tools-common/types v1.0.0 h1:C9MjHmTPcZyPo2Yp9Dt86WeZH+2XQg github.com/couchbase/tools-common/types v1.0.0/go.mod h1:r700V2xUuJqBGNG2aWbQYn5S0sJdqO3TLIa2AIQVaGU= github.com/couchbase/tools-common/utils v1.0.0 h1:6mWXqWWj7aM0Kp2LWpSKEu9pLAYm7il3gWdqpvxnJV4= github.com/couchbase/tools-common/utils v1.0.0/go.mod h1:i6cN5Z5hB9vQRLxe2j1v6Nu8bv+pKl9BFXjbQUHSah8= +github.com/couchbasedeps/mochi-mqtt-server/v2 v2.0.0-20240515222400-b57f42aef8ae h1:72m0FfJuQ4EHHQXyC593aGgvLCF/y4wZIjXhmXqK7Zo= +github.com/couchbasedeps/mochi-mqtt-server/v2 v2.0.0-20240515222400-b57f42aef8ae/go.mod h1:TqztjKGO0/ArOjJt9x9idk0kqPT3CVN8Pb+l+PS5Gdo= github.com/couchbaselabs/go-fleecedelta v0.0.0-20220909152808-6d09efa7a338 h1:xMeDnMiapTiq8n8J83Mo2tPjQNIU7GssSsbQsP1CLOY= github.com/couchbaselabs/go-fleecedelta v0.0.0-20220909152808-6d09efa7a338/go.mod h1:0f+dmhfcTKK+4quAe6rwqQUVVWtHX/eztNB8cmBUniQ= github.com/couchbaselabs/gocaves/client v0.0.0-20230404095311-05e3ba4f0259 h1:2TXy68EGEzIMHOx9UvczR5ApVecwCfQZ0LjkmwMI6g4= @@ -219,8 +221,6 @@ github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceT github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/mmcloughlin/geohash v0.10.0 h1:9w1HchfDfdeLc+jFEf/04D27KP7E2QmpDu52wPbJWRE= github.com/mmcloughlin/geohash v0.10.0/go.mod h1:oNZxQo5yWJh0eMQEP/8hwQuVx9Z9tjwFUqcTB1SmG0c= -github.com/mochi-mqtt/server/v2 v2.6.3 h1:LaaeGXkVH/1igCl9QYGTFzFb01E9RzKnIB8xUHGX/y8= -github.com/mochi-mqtt/server/v2 v2.6.3/go.mod h1:TqztjKGO0/ArOjJt9x9idk0kqPT3CVN8Pb+l+PS5Gdo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= From 152e4d86be3bdd2fce0cc74b58145b22c798f4b2 Mon Sep 17 00:00:00 2001 From: Jens Alfke Date: Fri, 17 May 2024 14:06:27 -0700 Subject: [PATCH 3/8] Fixed golangci-lint warnings --- base/http_listener.go | 3 +++ db/database.go | 2 +- mqtt/auth_test.go | 2 +- mqtt/cluster.go | 9 ++++++--- mqtt/persist_hook.go | 6 ++++-- mqtt/persister.go | 8 ++++---- mqtt/publish_hook.go | 5 ++++- mqtt/server.go | 2 +- mqtt/utils.go | 1 - rest/mqtt_server.go | 2 +- 10 files changed, 25 insertions(+), 15 deletions(-) diff --git a/base/http_listener.go b/base/http_listener.go index d163ba3ace..4a9e30fbac 100644 --- a/base/http_listener.go +++ b/base/http_listener.go @@ -52,6 +52,9 @@ func ListenAndServeHTTP(ctx context.Context, addr string, connLimit uint, certFi readTimeout, writeTimeout, readHeaderTimeout, idleTimeout time.Duration, http2Enabled bool, tlsMinVersion uint16) (serveFn func() error, listenerAddr net.Addr, server *http.Server, err error) { config, err := MakeTLSConfig(certFile, keyFile, tlsMinVersion) + if err != nil { + return nil, nil, nil, err + } if config != nil { protocolsEnabled := []string{"http/1.1"} if http2Enabled { diff --git a/db/database.go b/db/database.go index 508f990ae1..73a1220eb0 100644 --- a/db/database.go +++ b/db/database.go @@ -595,7 +595,7 @@ func (context *DatabaseContext) Close(ctx context.Context) { defer context.BucketLock.Unlock() for _, client := range context.MQTTClients { - client.Stop() + _ = client.Stop() } context.OIDCProviders.Stop() diff --git a/mqtt/auth_test.go b/mqtt/auth_test.go index 77dd9dfa59..6d6ee63154 100644 --- a/mqtt/auth_test.go +++ b/mqtt/auth_test.go @@ -18,7 +18,7 @@ import ( //======== MOCK USER -// Mock implementatin of User +// Mock implementation of User type MockUser struct { name string roleNames channels.TimedSet diff --git a/mqtt/cluster.go b/mqtt/cluster.go index 82b92f6cdf..7d2b382f4b 100644 --- a/mqtt/cluster.go +++ b/mqtt/cluster.go @@ -114,8 +114,8 @@ func startClusterAgent(server *Server) (agent *clusterAgent, err error) { func (agent *clusterAgent) stop() { if agent.peers != nil { base.InfofCtx(agent.ctx, base.KeyMQTT, "Cluster agent is saying goodbye...") - agent.peers.Leave(5 * time.Second) - agent.peers.Shutdown() + _ = agent.peers.Leave(5 * time.Second) + _ = agent.peers.Shutdown() agent.peers = nil } if agent.heartbeater != nil { @@ -231,7 +231,10 @@ func (agent *clusterAgent) NotifyMsg(message []byte) { // This is a Publish packet if packet, err := decodePacket(message[2:], message[1]); err == nil { base.InfofCtx(agent.ctx, base.KeyMQTT, "Relaying PUBLISH packet from peer for topic %q (%d bytes)", packet.TopicName, len(packet.Payload)) - agent.broker.Publish(packet.TopicName, packet.Payload, packet.FixedHeader.Retain, packet.FixedHeader.Qos) + err = agent.broker.Publish(packet.TopicName, packet.Payload, packet.FixedHeader.Retain, packet.FixedHeader.Qos) + if err != nil { + base.ErrorfCtx(agent.ctx, "MQTT cluster error publishing message forwarded from peer: %v", err) + } } else { base.ErrorfCtx(agent.ctx, "MQTT cluster error decoding packet: %v", err) } diff --git a/mqtt/persist_hook.go b/mqtt/persist_hook.go index a6517ba5bb..9d7d20178b 100644 --- a/mqtt/persist_hook.go +++ b/mqtt/persist_hook.go @@ -149,7 +149,9 @@ func (h *persistHook) OnUnsubscribed(cl *mochi.Client, pk packets.Packet) { func (h *persistHook) OnPublished(cl *mochi.Client, pk packets.Packet) { defer base.FatalPanicHandler() if pk.FixedHeader.Qos >= 1 { - h.persist.persistPublishedMessage(cl, pk) + if err := h.persist.persistPublishedMessage(cl, pk); err != nil { + h.Log.Error("failed to save published message", "error", err, "session", cl.ID) + } } } @@ -162,7 +164,7 @@ func (h *persistHook) OnRetainMessage(cl *mochi.Client, pk packets.Packet, resul h.Log.Error("failed to save retained message data", "error", err) } } else if result < 0 { - h.persist.deleteRetainedMessage(pk) + _ = h.persist.deleteRetainedMessage(pk) } } diff --git a/mqtt/persister.go b/mqtt/persister.go index f28f57a5d7..dd01824073 100644 --- a/mqtt/persister.go +++ b/mqtt/persister.go @@ -107,7 +107,7 @@ func (p *persister) disconnectClient(cl *mochi.Client) error { // Deletes a client's session data. func (p *persister) deleteClient(cl *mochi.Client) { - p.deleteDoc(clientKey(cl), "expired client data") + _ = p.deleteDoc(clientKey(cl), "expired client data") } // Finds a stored session by ID & username, returning the relevant data. @@ -135,7 +135,7 @@ func (p *persister) getStoredClient(id string, username []byte) (oldRemote strin filters := NewTopicMap[bool](len(subs)) for _, sub := range subs { if sub.Qos > 0 { - filters.AddFilter(sub.Filter, true) + _ = filters.AddFilter(sub.Filter, true) } } @@ -216,8 +216,8 @@ func (p *persister) persistRetainedMessage(pk packets.Packet) error { } // Deletes the 'retained' message of a topic. -func (p *persister) deleteRetainedMessage(pk packets.Packet) { - p.deleteDoc(retainedMessageKey(pk.TopicName), "retained message data") +func (p *persister) deleteRetainedMessage(pk packets.Packet) error { + return p.deleteDoc(retainedMessageKey(pk.TopicName), "retained message data") } //======== QUERIES: diff --git a/mqtt/publish_hook.go b/mqtt/publish_hook.go index 4a7f37cc7c..6cd973f884 100644 --- a/mqtt/publish_hook.go +++ b/mqtt/publish_hook.go @@ -68,7 +68,10 @@ func (h *publishHook) OnPublish(client *mochi.Client, packet packets.Packet) (pa } if agent := h.server.clusterAgent; agent != nil { - agent.broadcastPublish(&packet) + if err := agent.broadcastPublish(&packet); err != nil { + base.ErrorfCtx(h.ctx, "MQTT: Failed to broadcast published message to cluster: %v", + err) + } } } else { diff --git a/mqtt/server.go b/mqtt/server.go index 43193a6574..12f5356242 100644 --- a/mqtt/server.go +++ b/mqtt/server.go @@ -191,7 +191,7 @@ func (server *Server) Start() error { } if err := server.broker.Serve(); err != nil { base.ErrorfCtx(server.ctx, "MQTT: starting MQTT broker: %v", err) - server.Stop() + _ = server.Stop() return fmt.Errorf("error starting MQTT broker: %w", err) } return nil diff --git a/mqtt/utils.go b/mqtt/utils.go index 02c6d2a7a1..42b803c5e0 100644 --- a/mqtt/utils.go +++ b/mqtt/utils.go @@ -47,7 +47,6 @@ func getIPAddress(ipv6 bool) (string, error) { func makeRealIPAddress(addrStr string, keepPort bool) (string, error) { host, port, err := net.SplitHostPort(addrStr) if err != nil { - err = nil host = addrStr } diff --git a/rest/mqtt_server.go b/rest/mqtt_server.go index faa72f2953..3f74da13d5 100644 --- a/rest/mqtt_server.go +++ b/rest/mqtt_server.go @@ -53,6 +53,6 @@ func (sc *ServerContext) StartMQTTServer(ctx context.Context, config *mqtt.Serve return err } sc.mqttServer = server - go server.Start() + go func() { _ = server.Start() }() return nil } From 2f825d6b8781756b7f677e87669a93d7ecd4fac8 Mon Sep 17 00:00:00 2001 From: Jens Alfke Date: Fri, 17 May 2024 14:08:48 -0700 Subject: [PATCH 4/8] Fixed a test failure --- mqtt/modeler_test.go | 2 +- mqtt/templater.go | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/mqtt/modeler_test.go b/mqtt/modeler_test.go index 302b8366cc..f0d258055d 100644 --- a/mqtt/modeler_test.go +++ b/mqtt/modeler_test.go @@ -16,7 +16,7 @@ import ( ) func TestStateTemplate(t *testing.T) { - template := Body{"temp": "${message.payload.temperature}", "time": "${date}", "foo": "bar"} + template := Body{"temp": "${message.payload.temperature}", "time": "${now.iso8601}", "foo": "bar"} topic := TopicMatch{Name: "temp"} payload := Body{"temperature": 98.5} diff --git a/mqtt/templater.go b/mqtt/templater.go index 6f77099f85..d43d2213c6 100644 --- a/mqtt/templater.go +++ b/mqtt/templater.go @@ -25,10 +25,6 @@ const ( maxValidTimestamp = int64(9999999999) ) -//TODO: Substitute `$` anywhere in a value, not just the entire value! 4/12/24 -//TODO: Support `[n]` for array indexing -//TODO: Support some type of transformer/filter, i.e. to format date from int->ISO8601 - // Applies a template to a message type templater struct { payload any // The message payload; any parsed JSON type From bd38d3db2b5c75f1d3a69d83003bfcf1a5f80349 Mon Sep 17 00:00:00 2001 From: Jens Alfke Date: Fri, 17 May 2024 14:48:10 -0700 Subject: [PATCH 5/8] Work around a test failure in CI (no IPv6) --- mqtt/utils.go | 2 ++ mqtt/utils_test.go | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/mqtt/utils.go b/mqtt/utils.go index 42b803c5e0..69208ee3e8 100644 --- a/mqtt/utils.go +++ b/mqtt/utils.go @@ -14,6 +14,8 @@ import ( ) // Returns a real IPv4 or IPv6 address for this computer. +// If none can be found, returns an empty string; an error is only returned if it was unable to +// get the list of network interfaces. func getIPAddress(ipv6 bool) (string, error) { interfaces, err := net.Interfaces() if err != nil { diff --git a/mqtt/utils_test.go b/mqtt/utils_test.go index deaac4460c..1356999345 100644 --- a/mqtt/utils_test.go +++ b/mqtt/utils_test.go @@ -25,7 +25,7 @@ func TestGetMyAddress(t *testing.T) { addr, err = getIPAddress(true) require.NoError(t, err) - require.NotEmpty(t, addr) + //require.NotEmpty(t, addr) // Disabled assertion since not every network has IPv6 log.Printf("IPv6 = %s", addr) } From ece4fca283545ca537636fa3f6c83f1a52309e10 Mon Sep 17 00:00:00 2001 From: Jens Alfke Date: Fri, 17 May 2024 14:48:31 -0700 Subject: [PATCH 6/8] Added config flags for the new MQTT options --- mqtt/server.go | 6 +++--- rest/config_flags.go | 10 ++++++++++ rest/config_startup.go | 4 +++- rest/mqtt_server.go | 2 +- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/mqtt/server.go b/mqtt/server.go index 12f5356242..0ea18f353d 100644 --- a/mqtt/server.go +++ b/mqtt/server.go @@ -30,7 +30,7 @@ const kDefaultMaximumSessionExpiryInterval = 60 * 60 * 24 type ServerConfig struct { Enabled *bool `json:"enabled,omitempty"` PublicInterface string `json:"public_interface,omitempty" help:"Network interface to bind MQTT server to"` - MetadataDB *string `json:"metadata_db,omitempty" help:"Name of database to persist MQTT state to"` + MetadataDB string `json:"metadata_db,omitempty" help:"Name of database to persist MQTT state to"` Cluster *ClusterConfig `json:"cluster,omitempty" help:"Cluster configuration (omit for no clustering)"` MaximumMessageExpiryInterval int64 `json:"maximum_message_expiry_interval,omitempty" help:"Maximum message lifetime, in seconds; 0 means default"` MaximumSessionExpiryInterval uint32 `json:"maximum_session_expiry_interval,omitempty" help:"Maximum disconnected session lifetime, in seconds; 0 means default"` @@ -50,8 +50,8 @@ func (config *ServerConfig) IsEnabled() bool { } func (config *ServerConfig) ParseMetadataStore() (db string, ds sgbucket.DataStoreName, err error) { - if config.MetadataDB != nil { - pieces := strings.Split(*config.MetadataDB, sgbucket.ScopeCollectionSeparator) + if config.MetadataDB != "" { + pieces := strings.Split(config.MetadataDB, sgbucket.ScopeCollectionSeparator) db = pieces[0] scope := sgbucket.DefaultScope collection := sgbucket.DefaultCollection diff --git a/rest/config_flags.go b/rest/config_flags.go index 72ec00c4f4..d71eaaec7e 100644 --- a/rest/config_flags.go +++ b/rest/config_flags.go @@ -64,6 +64,15 @@ func registerConfigFlags(config *StartupConfig, fs *flag.FlagSet) map[string]con "api.cors.headers": {&config.API.CORS.Headers, fs.String("api.cors.headers", "", "List of comma separated allowed headers")}, "api.cors.max_age": {&config.API.CORS.MaxAge, fs.Int("api.cors.max_age", 0, "Maximum age of the CORS Options request")}, + "api.mqtt.enabled": {&config.API.MQTT.Enabled, fs.Bool("api.mqtt.enabled", true, "Set to false to disable MQTT")}, + "api.mqtt.public_interface": {&config.API.MQTT.PublicInterface, fs.String("api.mqtt.public_interface", "", "Network interface to listen for MQTT connections")}, + "api.mqtt.metadata_db": {&config.API.MQTT.MetadataDB, fs.String("api.mqtt.metadata_db", "", "Name of database to persist MQTT state to")}, + "api.mqtt.maximum_message_expiry_interval": {&config.API.MQTT.MaximumMessageExpiryInterval, fs.Int64("api.mqtt.maximum_message_expiry_interval", 0, "Maximum message lifetime, in seconds; 0 means default")}, + "api.mqtt.maximum_session_expiry_interval": {&config.API.MQTT.MaximumSessionExpiryInterval, fs.Int64("api.mqtt.maximum_session_expiry_interval", 0, "Maximum disconnected session lifetime, in seconds; 0 means default")}, + + "api.mqtt.cluster.enabled": {&config.API.MQTT.Cluster.Enabled, fs.Bool("api.mqtt.cluster.enabled", true, "Set to false to disable MQTT clustering")}, + "api.mqtt.cluster.discovery_address": {&config.API.MQTT.Cluster.DiscoveryAddr, fs.String("api.mqtt.cluster.discovery_address", "", "Address+port for peer discovery and gossip")}, + "logging.log_file_path": {&config.Logging.LogFilePath, fs.String("logging.log_file_path", "", "Absolute or relative path on the filesystem to the log file directory. A relative path is from the directory that contains the Sync Gateway executable file")}, "logging.redaction_level": {&config.Logging.RedactionLevel, fs.String("logging.redaction_level", "", "Redaction level to apply to log output. Options: none, partial, full, unset")}, @@ -138,6 +147,7 @@ func registerConfigFlags(config *StartupConfig, fs *flag.FlagSet) map[string]con "unsupported.allow_dbconfig_env_vars": {&config.Unsupported.AllowDbConfigEnvVars, fs.Bool("unsupported.allow_dbconfig_env_vars", true, "Can be set to false to skip environment variable expansion in database configs")}, "unsupported.user_queries": {&config.Unsupported.UserQueries, fs.Bool("unsupported.user_queries", false, "Whether user-query APIs are enabled")}, + "unsupported.mqtt": {&config.Unsupported.MQTT, fs.Bool("unsupported.mqtt", false, "Whether MQTT is enabled")}, "database_credentials": {&config.DatabaseCredentials, fs.String("database_credentials", "null", "JSON-encoded per-database credentials, that can be used instead of the bootstrap ones. This will override bucket_credentials that target the bucket that the database is in.")}, "bucket_credentials": {&config.BucketCredentials, fs.String("bucket_credentials", "null", "JSON-encoded per-bucket credentials, that can be used instead of the bootstrap ones.")}, diff --git a/rest/config_startup.go b/rest/config_startup.go index c872cda949..d091bcd6c3 100644 --- a/rest/config_startup.go +++ b/rest/config_startup.go @@ -230,7 +230,9 @@ func NewEmptyStartupConfig() StartupConfig { return StartupConfig{ API: APIConfig{ CORS: &auth.CORSConfig{}, - MQTT: &mqtt.ServerConfig{}, + MQTT: &mqtt.ServerConfig{ + Cluster: &mqtt.ClusterConfig{Enabled: base.BoolPtr(false)}, + }, }, Logging: base.LoggingConfig{ Console: &base.ConsoleLoggerConfig{}, diff --git a/rest/mqtt_server.go b/rest/mqtt_server.go index 3f74da13d5..352b822329 100644 --- a/rest/mqtt_server.go +++ b/rest/mqtt_server.go @@ -33,7 +33,7 @@ func (sc *ServerContext) StartMQTTServer(ctx context.Context, config *mqtt.Serve // Metadata store: var metadataStore sgbucket.DataStore - if config.MetadataDB != nil { + if config.MetadataDB != "" { dbName, dsName, err := config.ParseMetadataStore() if err != nil { return err From f96a4c65cacbf6394a68ff96b75147f5e5d805a4 Mon Sep 17 00:00:00 2001 From: Jens Alfke Date: Fri, 17 May 2024 15:37:36 -0700 Subject: [PATCH 7/8] The config/flags code is very fragile about config types... --- mqtt/persister.go | 4 ++-- mqtt/server.go | 8 ++++---- rest/config_flags.go | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mqtt/persister.go b/mqtt/persister.go index dd01824073..48aa22922d 100644 --- a/mqtt/persister.go +++ b/mqtt/persister.go @@ -194,7 +194,7 @@ func (p *persister) clientInflights(cl *mochi.Client) (inflights []storage.Messa // Computes expiration time of session document: func (p *persister) clientExpiry(cl *mochi.Client, online bool) uint32 { - exp := base.Min(cl.Properties.Props.SessionExpiryInterval, p.config.MaximumSessionExpiryInterval) + exp := base.Min(uint(cl.Properties.Props.SessionExpiryInterval), p.config.MaximumSessionExpiryInterval) if online { exp = base.Max(exp, 2*kClientDocRefreshInterval) } @@ -305,7 +305,7 @@ func (p *persister) messageExpiry(pk *packets.Packet) uint32 { if exp == 0 { exp = uint32(p.config.MaximumMessageExpiryInterval) } - if sessexp := p.config.MaximumSessionExpiryInterval; sessexp < exp { + if sessexp := uint32(p.config.MaximumSessionExpiryInterval); sessexp < exp { // No point keeping a message past the expiration of any session that wants it exp = sessexp } diff --git a/mqtt/server.go b/mqtt/server.go index 0ea18f353d..da9f577766 100644 --- a/mqtt/server.go +++ b/mqtt/server.go @@ -32,8 +32,8 @@ type ServerConfig struct { PublicInterface string `json:"public_interface,omitempty" help:"Network interface to bind MQTT server to"` MetadataDB string `json:"metadata_db,omitempty" help:"Name of database to persist MQTT state to"` Cluster *ClusterConfig `json:"cluster,omitempty" help:"Cluster configuration (omit for no clustering)"` - MaximumMessageExpiryInterval int64 `json:"maximum_message_expiry_interval,omitempty" help:"Maximum message lifetime, in seconds; 0 means default"` - MaximumSessionExpiryInterval uint32 `json:"maximum_session_expiry_interval,omitempty" help:"Maximum disconnected session lifetime, in seconds; 0 means default"` + MaximumMessageExpiryInterval uint `json:"maximum_message_expiry_interval,omitempty" help:"Maximum message lifetime, in seconds; 0 means default"` + MaximumSessionExpiryInterval uint `json:"maximum_session_expiry_interval,omitempty" help:"Maximum disconnected session lifetime, in seconds; 0 means default"` } type ClusterConfig struct { @@ -121,12 +121,12 @@ func NewServer( if config.MaximumMessageExpiryInterval <= 0 { config.MaximumMessageExpiryInterval = kDefaultMaximumMessageExpiryInterval } - opts.Capabilities.MaximumMessageExpiryInterval = config.MaximumMessageExpiryInterval + opts.Capabilities.MaximumMessageExpiryInterval = int64(config.MaximumMessageExpiryInterval) if config.MaximumSessionExpiryInterval <= 0 { config.MaximumSessionExpiryInterval = kDefaultMaximumSessionExpiryInterval } - opts.Capabilities.MaximumSessionExpiryInterval = config.MaximumSessionExpiryInterval + opts.Capabilities.MaximumSessionExpiryInterval = uint32(config.MaximumSessionExpiryInterval) persister := &persister{ ctx: ctx, diff --git a/rest/config_flags.go b/rest/config_flags.go index d71eaaec7e..43628a37ad 100644 --- a/rest/config_flags.go +++ b/rest/config_flags.go @@ -67,8 +67,8 @@ func registerConfigFlags(config *StartupConfig, fs *flag.FlagSet) map[string]con "api.mqtt.enabled": {&config.API.MQTT.Enabled, fs.Bool("api.mqtt.enabled", true, "Set to false to disable MQTT")}, "api.mqtt.public_interface": {&config.API.MQTT.PublicInterface, fs.String("api.mqtt.public_interface", "", "Network interface to listen for MQTT connections")}, "api.mqtt.metadata_db": {&config.API.MQTT.MetadataDB, fs.String("api.mqtt.metadata_db", "", "Name of database to persist MQTT state to")}, - "api.mqtt.maximum_message_expiry_interval": {&config.API.MQTT.MaximumMessageExpiryInterval, fs.Int64("api.mqtt.maximum_message_expiry_interval", 0, "Maximum message lifetime, in seconds; 0 means default")}, - "api.mqtt.maximum_session_expiry_interval": {&config.API.MQTT.MaximumSessionExpiryInterval, fs.Int64("api.mqtt.maximum_session_expiry_interval", 0, "Maximum disconnected session lifetime, in seconds; 0 means default")}, + "api.mqtt.maximum_message_expiry_interval": {&config.API.MQTT.MaximumMessageExpiryInterval, fs.Uint("api.mqtt.maximum_message_expiry_interval", 0, "Maximum message lifetime, in seconds; 0 means default")}, + "api.mqtt.maximum_session_expiry_interval": {&config.API.MQTT.MaximumSessionExpiryInterval, fs.Uint("api.mqtt.maximum_session_expiry_interval", 0, "Maximum disconnected session lifetime, in seconds; 0 means default")}, "api.mqtt.cluster.enabled": {&config.API.MQTT.Cluster.Enabled, fs.Bool("api.mqtt.cluster.enabled", true, "Set to false to disable MQTT clustering")}, "api.mqtt.cluster.discovery_address": {&config.API.MQTT.Cluster.DiscoveryAddr, fs.String("api.mqtt.cluster.discovery_address", "", "Address+port for peer discovery and gossip")}, From e57c9669dbca1b037daded7f7a7075cbff14cf60 Mon Sep 17 00:00:00 2001 From: Jens Alfke Date: Tue, 21 May 2024 11:15:20 -0700 Subject: [PATCH 8/8] Implemented time-series rollover, and sub-second precision --- mqtt/auth_hook.go | 16 ++-- mqtt/client.go | 4 +- mqtt/cluster.go | 4 +- mqtt/config.go | 130 +++++++++++++++---------- mqtt/ingest.go | 122 ++++++++++++++++-------- mqtt/ingest_test.go | 193 ++++++++++++++++++++++++++++++++++++++ mqtt/modeler.go | 15 --- mqtt/publish_hook.go | 14 +-- mqtt/templater.go | 4 +- mqtt/topic_rename_hook.go | 6 +- mqtt/utils.go | 28 +++++- 11 files changed, 405 insertions(+), 131 deletions(-) create mode 100644 mqtt/ingest_test.go diff --git a/mqtt/auth_hook.go b/mqtt/auth_hook.go index b66508d2c2..cb288c848b 100644 --- a/mqtt/auth_hook.go +++ b/mqtt/auth_hook.go @@ -43,13 +43,13 @@ func (h *authHook) OnConnectAuthenticate(client *mochi.Client, pk packets.Packet defer base.FatalPanicHandler() dbc, username := h.server.clientDatabaseContext(client) if dbc == nil { - base.WarnfCtx(h.ctx, "MQTT connection attempt failed with username %q -- does not begin with a database name", client.Properties.Username) + base.WarnfCtx(h.ctx, "MQTT connection attempt failed with username %q -- does not begin with a database name", base.UD(client.Properties.Username)) return false } user, _ := dbc.Authenticator(h.ctx).AuthenticateUser(username, string(pk.Connect.Password)) if user == nil { - base.WarnfCtx(h.ctx, "MQTT auth failure for username %q", client.Properties.Username) + base.WarnfCtx(h.ctx, "MQTT auth failure for username %q", base.UD(client.Properties.Username)) return false } @@ -59,11 +59,11 @@ func (h *authHook) OnConnectAuthenticate(client *mochi.Client, pk packets.Packet // If we continue, the broker will take over that session, which is a possible // security issue: this client would inherit its topic subscriptions. base.WarnfCtx(h.ctx, "MQTT auth failure for username %q: reusing session ID %q already belonging to user %q", - client.Properties.Username, pk.Connect.ClientIdentifier, existing.Properties.Username) + base.UD(client.Properties.Username), pk.Connect.ClientIdentifier, base.UD(existing.Properties.Username)) return false } - base.InfofCtx(h.ctx, base.KeyMQTT, "Client connection by user %q to db %q (session ID %q)", username, dbc.Name, client.ID) + base.InfofCtx(h.ctx, base.KeyMQTT, "Client connection by user %q to db %q (session ID %q)", base.UD(username), base.UD(dbc.Name), client.ID) return true } @@ -74,21 +74,21 @@ func (h *authHook) OnACLCheck(client *mochi.Client, topic string, write bool) (a dbc, username := h.server.clientDatabaseContext(client) topic, ok := stripDbNameFromTopic(dbc, topic) if !ok { - base.WarnfCtx(h.ctx, "MQTT: DB %s user %q tried to access topic %q not in that DB", dbc.Name, username, topic) + base.WarnfCtx(h.ctx, "MQTT: DB %s user %q tried to access topic %q not in that DB", base.UD(dbc.Name), base.UD(username), base.UD(topic)) return false } user, err := dbc.Authenticator(h.ctx).GetUser(username) if err != nil { - base.WarnfCtx(h.ctx, "MQTT: OnACLCheck: Can't find DB user for MQTT client username %q", username) + base.WarnfCtx(h.ctx, "MQTT: OnACLCheck: Can't find DB user for MQTT client username %q", base.UD(username)) return false } allowed = dbcSettings(dbc).Authorize(user, topic, write) if allowed { - base.InfofCtx(h.ctx, base.KeyMQTT, "DB %s user %q accessing topic %q, write=%v", dbc.Name, username, topic, write) + base.InfofCtx(h.ctx, base.KeyMQTT, "DB %s user %q accessing topic %q, write=%v", base.UD(dbc.Name), base.UD(username), base.UD(topic), write) } else { - base.InfofCtx(h.ctx, base.KeyMQTT, "DB %s user %q blocked from accessing topic %q, write=%v", dbc.Name, username, topic, write) + base.InfofCtx(h.ctx, base.KeyMQTT, "DB %s user %q blocked from accessing topic %q, write=%v", base.UD(dbc.Name), base.UD(username), base.UD(topic), write) } return } diff --git a/mqtt/client.go b/mqtt/client.go index aedeeea8cd..a5c4fe5b5e 100644 --- a/mqtt/client.go +++ b/mqtt/client.go @@ -147,9 +147,9 @@ func (client *Client) onPublishReceived(pub paho.PublishReceived) (bool, error) exp = base.SecondsToCbsExpiry(int(*msgExp)) } - err := IngestMessage(client.ctx, *match, pub.Packet.Payload, sub, client.database, exp) + err := ingestMessage(client.ctx, *match, pub.Packet.Payload, sub, client.database, exp) if err != nil { - base.WarnfCtx(client.ctx, "MQTT Client %q failed to save message from topic %q: %v", client.config.Broker.ClientID, pub.Packet.Topic, err) + base.WarnfCtx(client.ctx, "MQTT Client %q failed to save message from topic %q: %v", client.config.Broker.ClientID, base.UD(pub.Packet.Topic), err) } return (err == nil), err } diff --git a/mqtt/cluster.go b/mqtt/cluster.go index 7d2b382f4b..a6ac982abc 100644 --- a/mqtt/cluster.go +++ b/mqtt/cluster.go @@ -184,7 +184,7 @@ func (agent *clusterAgent) broadcastPublish(packet *packets.Packet) error { return err } b := &packetBroadcast{data: buf.Bytes()} - base.InfofCtx(agent.ctx, base.KeyMQTT, "Broadcasting Publish packet for topic %q", packet.TopicName) + base.InfofCtx(agent.ctx, base.KeyMQTT, "Broadcasting Publish packet for topic %q", base.UD(packet.TopicName)) agent.broadcastQueue.QueueBroadcast(b) } return nil @@ -230,7 +230,7 @@ func (agent *clusterAgent) NotifyMsg(message []byte) { case 'P': // This is a Publish packet if packet, err := decodePacket(message[2:], message[1]); err == nil { - base.InfofCtx(agent.ctx, base.KeyMQTT, "Relaying PUBLISH packet from peer for topic %q (%d bytes)", packet.TopicName, len(packet.Payload)) + base.InfofCtx(agent.ctx, base.KeyMQTT, "Relaying PUBLISH packet from peer for topic %q (%d bytes)", base.UD(packet.TopicName), len(packet.Payload)) err = agent.broker.Publish(packet.TopicName, packet.Payload, packet.FixedHeader.Retain, packet.FixedHeader.Qos) if err != nil { base.ErrorfCtx(agent.ctx, "MQTT cluster error publishing message forwarded from peer: %v", err) diff --git a/mqtt/config.go b/mqtt/config.go index 1651d85e7f..0c02fb1486 100644 --- a/mqtt/config.go +++ b/mqtt/config.go @@ -89,12 +89,11 @@ type IngestConfig struct { Scope string `json:"scope,omitempty"` // Scope to save to Collection string `json:"collection,omitempty"` // Collection to save to Encoding *string `json:"payload_encoding,omitempty"` // How to parse payload (default "string") - Model *string `json:"model,omitempty"` // Save mode: "state" (default) or "time_series" + Model *string `json:"model,omitempty"` // Save mode: "state" (default), "time_series", "space_time_series" StateTemplate Body `json:"state,omitempty"` // Document properties template TimeSeries *TimeSeriesConfig `json:"time_series,omitempty"` SpaceTimeSeries *SpaceTimeSeriesConfig `json:"space_time_series,omitempty"` - QoS *int `json:"qos,omitempty"` // QoS of subscription, client-side only (default: 2) - Channels []string `json:"channels,omitempty"` // Channel access of doc + QoS *int `json:"qos,omitempty"` // QoS of subscription, client-side only (default: 2) } type IngestMap map[string]*IngestConfig @@ -140,14 +139,14 @@ func (bc *BrokerConfig) Validate() error { return err } } - return validateSubscriptions(bc.Ingest) + return validateIngestMap(bc.Ingest) } func (config *ClientConfig) Validate() error { if url, _ := url.Parse(config.Broker.URL); url == nil { return fmt.Errorf("invalid broker URL `%s`", config.Broker.URL) } - return validateSubscriptions(config.Ingest) + return validateIngestMap(config.Ingest) } func (config *TimeSeriesConfig) Validate() error { @@ -192,59 +191,92 @@ func (config *TimeSeriesConfig) validateRotation() error { return nil } -func validateSubscriptions(subs IngestMap) error { - for topic, sub := range subs { - if sub.Scope == "" { - sub.Scope = base.DefaultScopeAndCollectionName().Scope - } - if sub.Collection == "" { - sub.Collection = base.DefaultScopeAndCollectionName().Collection +// Validates a map as a template for the "state" model. +func validateStateTemplate(template Body) error { + if template != nil { + tmpl := templater{ + payload: Body{}, + timestamp: time.Now(), + allowMissingProperties: true} + tmpl.apply(template) + if tmpl.err != nil { + return tmpl.err } - if !sgbucket.IsValidDataStoreName(sub.Scope, sub.Collection) { - return fmt.Errorf("invalid scope/collection names %q, %q in subscription %q", - sub.Scope, sub.Collection, topic) + } + return nil +} + +func (cfg *IngestConfig) Validate(topic string) error { + if cfg.Scope == "" { + cfg.Scope = base.DefaultScopeAndCollectionName().Scope + } + if cfg.Collection == "" { + cfg.Collection = base.DefaultScopeAndCollectionName().Collection + } + if !sgbucket.IsValidDataStoreName(cfg.Scope, cfg.Collection) { + return fmt.Errorf("invalid scope/collection names %q, %q in ingest config %q", + cfg.Scope, cfg.Collection, topic) + } + + if _, err := MakeTopicFilter(topic); err != nil { + return err + } + if cfg.QoS != nil && (*cfg.QoS < 0 || *cfg.QoS > 2) { + return fmt.Errorf("invalid `qos` value %v in ingest config %q", *cfg.QoS, topic) + } + if xform := cfg.Encoding; xform != nil { + if *xform != EncodingString && *xform != EncodingBase64 && *xform != EncodingJSON { + return fmt.Errorf("invalid `transform` option %q in ingest config %q", *xform, topic) } + } - if _, err := MakeTopicFilter(topic); err != nil { + // Check the `StateTemplate`, `TimeSeries`, `SpaceTimeSeries` properties: + inferredModel := ModelState + modelProperties := 0 + if cfg.StateTemplate != nil { + if err := validateStateTemplate(cfg.StateTemplate); err != nil { return err } - if sub.QoS != nil && (*sub.QoS < 0 || *sub.QoS > 2) { - return fmt.Errorf("invalid `qos` value %v in subscription %q", *sub.QoS, topic) + modelProperties += 1 + } + if cfg.TimeSeries != nil { + if err := cfg.TimeSeries.Validate(); err != nil { + return err } - if xform := sub.Encoding; xform != nil { - if *xform != EncodingString && *xform != EncodingBase64 && *xform != EncodingJSON { - return fmt.Errorf("invalid `transform` option %q in subscription %q", *xform, topic) - } + modelProperties += 1 + inferredModel = ModelTimeSeries + } + if cfg.SpaceTimeSeries != nil { + if err := cfg.SpaceTimeSeries.Validate(); err != nil { + return err } + modelProperties += 1 + inferredModel = ModelSpaceTimeSeries + } + if modelProperties > 1 { + return fmt.Errorf("multiple model properties in ingest config %q", topic) + } - if sub.Model != nil { - switch *sub.Model { - case ModelState: - if err := validateStateTemplate(sub.StateTemplate); err != nil { - return err - } else if sub.TimeSeries != nil || sub.SpaceTimeSeries != nil { - return fmt.Errorf("multiple model properties in subscription %q", topic) - } - case ModelTimeSeries: - if err := sub.TimeSeries.Validate(); err != nil { - return err - } else if sub.StateTemplate != nil || sub.SpaceTimeSeries != nil { - return fmt.Errorf("multiple model properties in subscription %q", topic) - } - case ModelSpaceTimeSeries: - if err := sub.SpaceTimeSeries.Validate(); err != nil { - return err - } else if sub.StateTemplate != nil || sub.TimeSeries != nil { - return fmt.Errorf("multiple model properties in subscription %q", topic) - } - default: - return fmt.Errorf("invalid `model` %q in subscription %q", *sub.Model, topic) - } - } else if sub.TimeSeries == nil && sub.StateTemplate == nil { - return fmt.Errorf("missing `model` subscription %q", topic) - } else if sub.TimeSeries != nil && sub.StateTemplate != nil { - return fmt.Errorf("cannot have both `state` and `time_series` in subscription %q", topic) + // Infer the `Model` property, or if given check that it matches: + if cfg.Model == nil { + cfg.Model = base.StringPtr(inferredModel) + } else if *cfg.Model != inferredModel { + switch *cfg.Model { + case ModelState, ModelTimeSeries, ModelSpaceTimeSeries: + return fmt.Errorf("ingest config %q has a %q property but \"model\": %q", topic, inferredModel, *cfg.Model) + default: + return fmt.Errorf("invalid \"model\": %q in ingest config %q", *cfg.Model, topic) } } return nil } + +func validateIngestMap(subs IngestMap) error { + var err error + for topic, sub := range subs { + if err = sub.Validate(topic); err != nil { + break + } + } + return err +} diff --git a/mqtt/ingest.go b/mqtt/ingest.go index 13cbed1e02..db056d6385 100644 --- a/mqtt/ingest.go +++ b/mqtt/ingest.go @@ -11,7 +11,6 @@ package mqtt import ( "context" "encoding/base64" - "encoding/json" "fmt" "reflect" "slices" @@ -23,20 +22,27 @@ import ( "github.com/couchbase/sync_gateway/db" ) +// Instead of using `sgbucket.DataStore`, define a smaller interface covering what we need. +// This makes it easier to mock out in tests; see `mockDataStore`. +type iDataStore interface { + sgbucket.KVStore + GetName() string +} + +// Internal struct used by `ingestMessage()` type ingester struct { ctx context.Context topic TopicMatch payload any sub *IngestConfig - model string - dataStore sgbucket.DataStore + dataStore iDataStore docID string exp uint32 timestamp time.Time } // Finds an IngestConfig that matches the given topic name. -func (bc *BrokerConfig) MatchIngest(topic string) (*IngestConfig, *TopicMatch) { +func (bc *BrokerConfig) matchIngest(topic string) (*IngestConfig, *TopicMatch) { bc.mutex.Lock() defer func() { bc.mutex.Unlock() }() @@ -50,8 +56,8 @@ func (bc *BrokerConfig) MatchIngest(topic string) (*IngestConfig, *TopicMatch) { return bc.ingestFilters.Match(topic) } -// Saves an incoming MQTT message to a document in a DataStore. Returns the docID. -func IngestMessage( +// Saves an incoming MQTT message to a document in a database. Returns the docID. +func ingestMessage( ctx context.Context, topic TopicMatch, rawPayload []byte, @@ -63,7 +69,18 @@ func IngestMessage( if err != nil { return err } + return ingestMessageToDataStore(ctx, topic, rawPayload, sub, dataStore, exp) +} +// Same as `ingestMessage` but takes a (i)DataStore. +func ingestMessageToDataStore( + ctx context.Context, + topic TopicMatch, + rawPayload []byte, + sub *IngestConfig, + dataStore iDataStore, + exp uint32, +) error { ing := ingester{ ctx: ctx, topic: topic, @@ -75,7 +92,7 @@ func IngestMessage( } // Parse the payload per the `encoding` config: - err = ing.decodePayload(rawPayload) + err := ing.decodePayload(rawPayload) if err != nil { return fmt.Errorf("failed to parse message from topic %q: %w", topic, err) } @@ -95,22 +112,11 @@ func IngestMessage( } } - // Infer model: - if sub.Model != nil { - ing.model = *sub.Model - } else if sub.StateTemplate != nil { - ing.model = ModelState - } else if sub.TimeSeries != nil { - ing.model = ModelTimeSeries - } else if sub.SpaceTimeSeries != nil { - ing.model = ModelSpaceTimeSeries - } - - switch ing.model { + switch *ing.sub.Model { case ModelState: err = ing.saveState() if err == nil { - base.InfofCtx(ing.ctx, base.KeyMQTT, "Saved msg as doc %q in db %s", ing.docID, ing.dataStore.GetName()) + base.InfofCtx(ing.ctx, base.KeyMQTT, "Saved msg as doc %q in db %s", base.UD(ing.docID), base.UD(ing.dataStore.GetName())) } case ModelTimeSeries: var entry []any @@ -169,7 +175,7 @@ func (ing *ingester) saveState() error { } else if err = ing.dataStore.Set(ing.docID, ing.exp, nil, body); err != nil { return err } - base.InfofCtx(ing.ctx, base.KeyMQTT, "Saved msg to doc %q in db %s", ing.docID, ing.dataStore.GetName()) + base.InfofCtx(ing.ctx, base.KeyMQTT, "Saved msg to doc %q in db %s", base.UD(ing.docID), base.UD(ing.dataStore.GetName())) return nil } @@ -177,22 +183,40 @@ func (ing *ingester) saveState() error { func (ing *ingester) saveTimeSeries(entry []any, seriesKey string, timeStampIndex int) error { _, err := ing.dataStore.Update(ing.docID, ing.exp, func(current []byte) (updated []byte, expiry *uint32, delete bool, err error) { var body Body - if err := base.JSONUnmarshal(current, &body); err != nil { + if err := base.JSONUnmarshal(current, &body); err != nil || body == nil { + // Ignore error (or empty doc); just clear the body to recover body = Body{} } + ts_new, _ := decodeTimestamp(entry[timeStampIndex]) + tsStart, hasStart := decodeTimestamp(body["ts_start"]) + if ing.shouldRotate(len(current), tsStart, ts_new) { + // Time to roll over the document -- save to a new key, and clear the contents + var added bool + added, err = ing.rotateTimeSeries(body) + if err == nil && !added { + // Race condition: someone else rotated the doc already. Reload and retry. + err = sgbucket.ErrCasFailureShouldRetry + } + if err != nil { + return + } + // Now that we've saved the time series, clear the doc and restart: + body = map[string]any{} + hasStart = false + } + body[seriesKey] = addToTimeSeries(body[seriesKey], entry, timeStampIndex) // Update start/end timestamps: - ts_new := entry[timeStampIndex].(int64) - if tsStart, ok := base.ToInt64(body["ts_start"]); !ok || ts_new < tsStart { + if !hasStart || ts_new < tsStart { body["ts_start"] = ts_new } - if tsEnd, ok := base.ToInt64(body["ts_end"]); !ok || ts_new > tsEnd { + if tsEnd, ok := decodeTimestamp(body["ts_end"]); !ok || ts_new > tsEnd { body["ts_end"] = ts_new } - if ing.model == ModelSpaceTimeSeries { + if *ing.sub.Model == ModelSpaceTimeSeries { // Update low/high geohashes: sp_new := entry[0].(string) if sp_low, ok := body["sp_low"].(string); !ok || sp_new < sp_low { @@ -220,18 +244,18 @@ func (ing *ingester) saveTimeSeries(entry []any, seriesKey string, timeStampInde return }) if err == nil { - base.InfofCtx(ing.ctx, base.KeyMQTT, "Appended msg to %s doc %q in db %s", ing.model, ing.docID, ing.dataStore.GetName()) + base.InfofCtx(ing.ctx, base.KeyMQTT, "Appended msg to %s doc %q in db %s", *ing.sub.Model, base.UD(ing.docID), base.UD(ing.dataStore.GetName())) } return err } // Adds an entry to a (space-)time-series array, in chronological order, unless it's a dup. func addToTimeSeries(seriesProp any, entry []any, timeStampIndex int) []any { - newTimeStamp := decodeTimestamp(entry[timeStampIndex]) + newTimeStamp, _ := decodeTimestamp(entry[timeStampIndex]) series, _ := seriesProp.([]any) for i, item := range series { if oldEntry, ok := item.([]any); ok && len(oldEntry) > timeStampIndex { - oldTimeStamp := decodeTimestamp(oldEntry[timeStampIndex]) + oldTimeStamp, _ := decodeTimestamp(oldEntry[timeStampIndex]) if newTimeStamp < oldTimeStamp { // New item comes before this one: return slices.Insert(series, i, any(entry)) @@ -244,16 +268,34 @@ func addToTimeSeries(seriesProp any, entry []any, timeStampIndex int) []any { return append(series, entry) } -func decodeTimestamp(n any) int64 { - switch n := n.(type) { - case int64: - return n - case float64: - return int64(n) - case json.Number: - if i, err := n.Int64(); err == nil { - return i - } +// Returns true if it's time to rotate a time-series doc, given its size and earliest timestamp. +func (ing *ingester) shouldRotate(docSize int, startTime float64, curTime float64) bool { + var rotationMaxSize int + var rotationInterval time.Duration + if ing.sub.TimeSeries != nil { + rotationMaxSize = ing.sub.TimeSeries.RotationMaxSize + rotationInterval = ing.sub.TimeSeries.rotationInterval + } else if ing.sub.SpaceTimeSeries != nil { + rotationMaxSize = ing.sub.SpaceTimeSeries.RotationMaxSize + rotationInterval = ing.sub.SpaceTimeSeries.rotationInterval + } + + if rotationMaxSize > 0 && docSize >= rotationMaxSize { + return true + } else if rotationInterval > 0 && startTime > 0 && curTime-startTime >= rotationInterval.Seconds() { + return true + } + return false +} + +// Called when a (parsed) time series doc is too large to add to. +// Copies the existing data to a new doc whose key has the first timestamp appended. +func (ing *ingester) rotateTimeSeries(body Body) (added bool, err error) { + var startStr string + if start, ok := decodeTimestamp(body["ts_start"]); ok { + m, _ := timeFromTimestamp(start).MarshalText() + startStr = string(m) } - return 0 + docID := fmt.Sprintf("%s @ %s", ing.docID, startStr) + return ing.dataStore.Add(docID, ing.exp, body) } diff --git a/mqtt/ingest_test.go b/mqtt/ingest_test.go new file mode 100644 index 0000000000..75129e8d39 --- /dev/null +++ b/mqtt/ingest_test.go @@ -0,0 +1,193 @@ +package mqtt + +import ( + "context" + "fmt" + "testing" + + sgbucket "github.com/couchbase/sg-bucket" + "github.com/couchbase/sync_gateway/base" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIngestTimeSeries(t *testing.T) { + const topicName = "temperature" + cfg := IngestConfig{ + DocID: "tempDoc", + Encoding: base.StringPtr("JSON"), + TimeSeries: &TimeSeriesConfig{ + TimeProperty: "${message.payload.when}", + TimeFormat: "unix_epoch", + ValuesTemplate: []any{"${message.payload.temperature}"}, + Rotation: "1d", + }, + } + require.NoError(t, cfg.Validate(topicName)) + + // Set up a mock DataStore that allows only Update to "tempDoc" and puts the body in `docBody`: + var docBody Body + dataStore := mockDataStore{ + update: func(k string, exp uint32, callback sgbucket.UpdateFunc) (casOut uint64, err error) { + require.Equal(t, cfg.DocID, k) + require.Equal(t, uint32(0), exp) + var rawDoc []byte + rawDoc, _, _, err = callback(base.MustJSONMarshal(t, docBody)) + if err == nil { + require.NoError(t, base.JSONUnmarshal(rawDoc, &docBody)) + } + return 1, err + }, + } + + topic := TopicMatch{Name: topicName} + + var expectedDocBody Body + + // Add first message: + t.Run("first message", func(t *testing.T) { + payload := []byte(`{"temperature": 98.5, "when": 1712864539}`) + err := ingestMessageToDataStore(context.TODO(), topic, payload, &cfg, &dataStore, 0) + require.NoError(t, err) + + expectedDocBody := map[string]any{ + "ts_data": []any{ + []any{1712864539.0, 98.5}, + }, + "ts_start": 1712864539.0, + "ts_end": 1712864539.0, + } + assert.EqualValues(t, expectedDocBody, docBody) + }) + + // Add another message: + t.Run("second message", func(t *testing.T) { + payload := []byte(`{"temperature": 99.3, "when": 1712889720}`) + err := ingestMessageToDataStore(context.TODO(), topic, payload, &cfg, &dataStore, 0) + require.NoError(t, err) + + expectedDocBody = map[string]any{ + "ts_data": []any{ + []any{1712864539.0, 98.5}, + []any{1712889720.0, 99.3}, + }, + "ts_start": 1712864539.0, + "ts_end": 1712889720.0, + } + assert.EqualValues(t, expectedDocBody, docBody) + }) + + // A later message triggering rotation: + t.Run("third message with rotation", func(t *testing.T) { + // Enable the DataStore.Add method, and save the key and value: + addCalls := 0 + var addedKey string + var addedValue any + dataStore.add = func(k string, exp uint32, v interface{}) (added bool, err error) { + require.Equal(t, addCalls, 0) + require.Equal(t, uint32(0), exp) + addCalls += 1 + addedKey = k + addedValue = v + return true, nil + } + + // Add an entry whose timestamp is more than a day after the first, triggering rotation: + payload := []byte(`{"temperature": 99.9, "when": 1713466800}`) + err := ingestMessageToDataStore(context.TODO(), topic, payload, &cfg, &dataStore, 0) + require.NoError(t, err) + + // Assert that the old doc body was backed up, with the first timestamp in the docID: + require.Equal(t, addCalls, 1) + assert.Equal(t, addedKey, "tempDoc @ 2024-04-11T12:42:19-07:00") + assert.EqualValues(t, expectedDocBody, addedValue) + + // Assert that the doc now contains just the new entry: + expectedDocBody = map[string]any{ + "ts_data": []any{ + []any{1713466800.0, 99.9}, + }, + "ts_start": 1713466800.0, + "ts_end": 1713466800.0, + } + assert.EqualValues(t, expectedDocBody, docBody) + }) +} + +//======== MOCK DATA STORE + +// A classic mock implementing sgbucket.KVStore as well as sgbucket.DataStore's GetName method. +// All methods but GetName are stubbed out to return an "unimplemented" error. +// The Add and Update methods can be plugged in as funcs. +type mockDataStore struct { + add func(k string, exp uint32, v interface{}) (added bool, err error) + update func(k string, exp uint32, callback sgbucket.UpdateFunc) (casOut uint64, err error) +} + +func (store *mockDataStore) GetName() string { return "mock" } +func (store *mockDataStore) Get(k string, rv interface{}) (cas uint64, err error) { + err = errUnmocked + return +} +func (store *mockDataStore) GetRaw(k string) (rv []byte, cas uint64, err error) { + err = errUnmocked + return +} +func (store *mockDataStore) GetAndTouchRaw(k string, exp uint32) (rv []byte, cas uint64, err error) { + err = errUnmocked + return +} +func (store *mockDataStore) Touch(k string, exp uint32) (cas uint64, err error) { + err = errUnmocked + return +} +func (store *mockDataStore) Add(k string, exp uint32, v interface{}) (added bool, err error) { + if store.add != nil { + return store.add(k, exp, v) + } else { + return false, errUnmocked + } +} +func (store *mockDataStore) AddRaw(k string, exp uint32, v []byte) (added bool, err error) { + err = errUnmocked + return +} +func (store *mockDataStore) Set(k string, exp uint32, opts *sgbucket.UpsertOptions, v interface{}) error { + return errUnmocked +} +func (store *mockDataStore) SetRaw(k string, exp uint32, opts *sgbucket.UpsertOptions, v []byte) error { + return errUnmocked +} +func (store *mockDataStore) WriteCas(k string, exp uint32, cas uint64, v interface{}, opt sgbucket.WriteOptions) (casOut uint64, err error) { + err = errUnmocked + return +} +func (store *mockDataStore) Delete(k string) error { + return errUnmocked +} +func (store *mockDataStore) Remove(k string, cas uint64) (casOut uint64, err error) { + err = errUnmocked + return +} +func (store *mockDataStore) Update(k string, exp uint32, callback sgbucket.UpdateFunc) (casOut uint64, err error) { + if store.update != nil { + return store.update(k, exp, callback) + } else { + return 0, errUnmocked + } +} +func (store *mockDataStore) Incr(k string, amt, def uint64, exp uint32) (casOut uint64, err error) { + err = errUnmocked + return +} +func (store *mockDataStore) GetExpiry(ctx context.Context, k string) (expiry uint32, err error) { + err = errUnmocked + return +} +func (store *mockDataStore) Exists(k string) (exists bool, err error) { err = errUnmocked; return } + +var errUnmocked = fmt.Errorf("unimplemented method in mockDataStore") + +var ( + _ iDataStore = &mockDataStore{} // test interface compliance +) diff --git a/mqtt/modeler.go b/mqtt/modeler.go index 1d2d88bb21..4f39a4f80d 100644 --- a/mqtt/modeler.go +++ b/mqtt/modeler.go @@ -32,21 +32,6 @@ func applyStateTemplate(template Body, payload any, timestamp time.Time, topic T } } -// Validates a map as a template for the "state" model. -func validateStateTemplate(template Body) error { - if template != nil { - tmpl := templater{ - payload: Body{}, - timestamp: time.Now(), - allowMissingProperties: true} - tmpl.apply(template) - if tmpl.err != nil { - return tmpl.err - } - } - return nil -} - // Applies a template to a message using the "time_series" model. // The first item of the result array will be a timestamp. func applyTimeSeriesTemplate(config *TimeSeriesConfig, payload any, timestamp time.Time, allowMissingProperties bool) ([]any, error) { diff --git a/mqtt/publish_hook.go b/mqtt/publish_hook.go index 6cd973f884..985f774ae5 100644 --- a/mqtt/publish_hook.go +++ b/mqtt/publish_hook.go @@ -53,18 +53,18 @@ func (h *publishHook) OnPublish(client *mochi.Client, packet packets.Packet) (pa topicName, ok := stripDbNameFromTopic(dbc, packet.TopicName) if !ok { base.ErrorfCtx(h.ctx, "MQTT: OnPublish received mismatched topic %q for client %q", - topicName, client.Properties.Username) + base.UD(topicName), base.UD(client.Properties.Username)) return packet, nil } - if config, topic := dbcSettings(dbc).MatchIngest(topicName); config != nil { - base.InfofCtx(h.ctx, base.KeyMQTT, "Ingesting message from client %q for db %q, topic %q", username, dbc.Name, topicName) - err := IngestMessage(h.ctx, *topic, packet.Payload, config, dbc, packet.Properties.MessageExpiryInterval) + if config, topic := dbcSettings(dbc).matchIngest(topicName); config != nil { + base.InfofCtx(h.ctx, base.KeyMQTT, "Ingesting message from client %q for db %q, topic %q", base.UD(username), base.UD(dbc.Name), base.UD(topicName)) + err := ingestMessage(h.ctx, *topic, packet.Payload, config, dbc, packet.Properties.MessageExpiryInterval) if err != nil { - base.WarnfCtx(h.ctx, "MQTT broker failed to save message in db %q from topic %q: %v", dbc.Name, topicName, err) + base.WarnfCtx(h.ctx, "MQTT broker failed to save message in db %q from topic %q: %v", base.UD(dbc.Name), base.UD(topicName), err) } } else { - base.DebugfCtx(h.ctx, base.KeyMQTT, "Client %q published non-persistent message in db %q, topic %q", username, dbc.Name, topicName) + base.DebugfCtx(h.ctx, base.KeyMQTT, "Client %q published non-persistent message in db %q, topic %q", base.UD(username), base.UD(dbc.Name), base.UD(topicName)) } if agent := h.server.clusterAgent; agent != nil { @@ -75,7 +75,7 @@ func (h *publishHook) OnPublish(client *mochi.Client, packet packets.Packet) (pa } } else { - base.DebugfCtx(h.ctx, base.KeyMQTT, "Relayed peer message to topic %q", packet.TopicName) + base.DebugfCtx(h.ctx, base.KeyMQTT, "Relayed peer message to topic %q", base.UD(packet.TopicName)) } return packet, nil diff --git a/mqtt/templater.go b/mqtt/templater.go index d43d2213c6..02ba4bb313 100644 --- a/mqtt/templater.go +++ b/mqtt/templater.go @@ -117,7 +117,7 @@ func (tmpl *templater) expandMatch(param string) (any, error) { if len(matches) == 1 { switch matches[0] { case "now": - // $now defaults to numeric timestamp (Unix epoch): + // $now defaults to numeric timestamp (Unix epoch, 1-second precision): return tmpl.timestamp.Unix(), nil default: // Is it a number? In that case, subsitute from the TopicMatch: @@ -140,7 +140,7 @@ func (tmpl *templater) expandMatch(param string) (any, error) { return "", err } case "unix": - // Insert numeric timestamp (Unix epoch): + // Insert numeric timestamp (Unix epoch, 1-second precision): return tmpl.timestamp.Unix(), nil } diff --git a/mqtt/topic_rename_hook.go b/mqtt/topic_rename_hook.go index f5c0d99330..54c2b804fd 100644 --- a/mqtt/topic_rename_hook.go +++ b/mqtt/topic_rename_hook.go @@ -44,8 +44,6 @@ func (h *topicRenameHook) OnPacketRead(client *mochi.Client, packet packets.Pack prefix += "/" fix := func(name *string) { if *name != "" && (*name)[0] != '$' { - base.DebugfCtx(h.ctx, base.KeyMQTT, "Incoming packet: renamed %q -> %q", - *name, prefix+*name) *name = prefix + *name } } @@ -68,11 +66,9 @@ func (h *topicRenameHook) OnPacketEncode(client *mochi.Client, packet packets.Pa prefix += "/" fix := func(name *string) { if strings.HasPrefix(*name, prefix) { - base.DebugfCtx(h.ctx, base.KeyMQTT, "Outgoing packet: renamed %q -> %q", - *name, (*name)[len(prefix):]) *name = (*name)[len(prefix):] } else if *name != "" && (*name)[0] != '$' { - base.WarnfCtx(h.ctx, "Unprefixed topic in outgoing packet: %q", *name) + base.WarnfCtx(h.ctx, "Unprefixed topic in outgoing packet: %q", base.UD(*name)) } } diff --git a/mqtt/utils.go b/mqtt/utils.go index 69208ee3e8..4a6438ebfe 100644 --- a/mqtt/utils.go +++ b/mqtt/utils.go @@ -9,10 +9,15 @@ package mqtt import ( + "encoding/json" "fmt" + "math" "net" + "time" ) +//======== IP ADDRESSES + // Returns a real IPv4 or IPv6 address for this computer. // If none can be found, returns an empty string; an error is only returned if it was unable to // get the list of network interfaces. @@ -29,7 +34,6 @@ func getIPAddress(ipv6 bool) (string, error) { if ip, _, err := net.ParseCIDR(addr.String()); err == nil { // The address must not be loopback, multicast, nor link-local: if !ip.IsLoopback() && !ip.IsLinkLocalMulticast() && !ip.IsLinkLocalUnicast() && !ip.IsMulticast() && !ip.IsUnspecified() { - //log.Printf("%s: [%s] %v", i.Name, i.Flags.String(), ip) // If it matches the IP version, return it: if (ip.To4() == nil) == ipv6 { return ip.String(), nil @@ -73,5 +77,27 @@ func makeRealIPAddress(addrStr string, keepPort bool) (string, error) { } else { return host, nil } +} + +//======== TIMESTAMPS + +// simply coerces int64, float64 or json.Number to float64. +func decodeTimestamp(n any) (float64, bool) { + switch n := n.(type) { + case int64: + return float64(n), true + case float64: + return n, true + case json.Number: + if i, err := n.Float64(); err == nil { + return i, true + } + } + return 0.0, false +} +// Converts a float64 timestamp to a Time value. +func timeFromTimestamp(ts float64) time.Time { + secs := math.Floor(ts) + return time.Unix(int64(secs), int64((ts-secs)*1.0e9)) }