178 lines
5.6 KiB
Go
178 lines
5.6 KiB
Go
package devicefleet
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"crypto/sha256"
|
||
|
|
"crypto/subtle"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"log/slog"
|
||
|
|
"net/url"
|
||
|
|
"sync"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/google/uuid"
|
||
|
|
mqtt "github.com/mochi-mqtt/server/v2"
|
||
|
|
"github.com/mochi-mqtt/server/v2/listeners"
|
||
|
|
"github.com/mochi-mqtt/server/v2/packets"
|
||
|
|
)
|
||
|
|
|
||
|
|
type connection struct{ device, token string }
|
||
|
|
type fleetHook struct {
|
||
|
|
mqtt.HookBase
|
||
|
|
service *Service
|
||
|
|
clients sync.Map
|
||
|
|
}
|
||
|
|
|
||
|
|
func (h *fleetHook) ID() string { return "voicelink" }
|
||
|
|
func (h *fleetHook) Provides(b byte) bool {
|
||
|
|
switch b {
|
||
|
|
case mqtt.OnConnectAuthenticate, mqtt.OnACLCheck, mqtt.OnSessionEstablished, mqtt.OnDisconnect, mqtt.OnPublish:
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
func bounded() (context.Context, context.CancelFunc) {
|
||
|
|
return context.WithTimeout(context.Background(), 5*time.Second)
|
||
|
|
}
|
||
|
|
func (h *fleetHook) OnConnectAuthenticate(cl *mqtt.Client, pk packets.Packet) bool {
|
||
|
|
if cl.ID == "" || cl.ID != string(pk.Connect.Username) || !pk.Connect.Clean {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
// Will and application data are never retained, and cannot target another device.
|
||
|
|
if pk.Connect.WillFlag && (pk.Connect.WillTopic != StatusTopic(cl.ID) || pk.Connect.WillRetain) {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
ctx, cancel := bounded()
|
||
|
|
defer cancel()
|
||
|
|
var device string
|
||
|
|
var hash []byte
|
||
|
|
err := h.service.Pool.QueryRow(ctx, `SELECT d.id::text,p.password_hash FROM device d JOIN fleet_presence p ON p.device_id=d.id JOIN tenant t ON t.id=d.tenant_id WHERE d.factory_device_id=$1 AND d.status='registered' AND d.activation_state NOT IN ('LOST','REVOKED') AND t.status='active'`, cl.ID).Scan(&device, &hash)
|
||
|
|
actual := sha256.Sum256(pk.Connect.Password)
|
||
|
|
if err != nil || subtle.ConstantTimeCompare(hash, actual[:]) != 1 {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
h.clients.Store(cl, connection{device, uuid.NewString()})
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
func (h *fleetHook) OnACLCheck(cl *mqtt.Client, topic string, write bool) bool {
|
||
|
|
if cl.Net.Inline {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
if _, ok := h.clients.Load(cl); !ok {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
if write {
|
||
|
|
return topic == StatusTopic(cl.ID)
|
||
|
|
}
|
||
|
|
return topic == OTATopic(cl.ID)
|
||
|
|
}
|
||
|
|
func (h *fleetHook) OnSessionEstablished(cl *mqtt.Client, _ packets.Packet) {
|
||
|
|
value, ok := h.clients.Load(cl)
|
||
|
|
if !ok {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
c := value.(connection)
|
||
|
|
ctx, cancel := bounded()
|
||
|
|
defer cancel()
|
||
|
|
_, err := h.service.Pool.Exec(ctx, `UPDATE fleet_presence SET connection_id=$2,connected=true,last_seen_at=NULL WHERE device_id=$1`, c.device, c.token)
|
||
|
|
if err != nil {
|
||
|
|
h.Log.Error("MQTT connection persistence failed")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
func (h *fleetHook) OnDisconnect(cl *mqtt.Client, _ error, _ bool) {
|
||
|
|
value, ok := h.clients.LoadAndDelete(cl)
|
||
|
|
if !ok {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
c := value.(connection)
|
||
|
|
ctx, cancel := bounded()
|
||
|
|
defer cancel()
|
||
|
|
_, err := h.service.Pool.Exec(ctx, `UPDATE fleet_presence SET connected=false WHERE device_id=$1 AND connection_id=$2`, c.device, c.token)
|
||
|
|
if err != nil {
|
||
|
|
h.Log.Error("MQTT disconnect persistence failed")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
type Heartbeat struct {
|
||
|
|
Online bool `json:"online"`
|
||
|
|
Version string `json:"firmware_version"`
|
||
|
|
Code int64 `json:"version_code"`
|
||
|
|
OTASupported bool `json:"ota_supported"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func ValidateHeartbeat(b Heartbeat) bool {
|
||
|
|
return b.Code >= 0 && len(b.Version) <= 80 && (!b.Online || b.Version != "")
|
||
|
|
}
|
||
|
|
func (h *fleetHook) OnPublish(cl *mqtt.Client, pk packets.Packet) (packets.Packet, error) {
|
||
|
|
if cl.Net.Inline {
|
||
|
|
return pk, nil
|
||
|
|
}
|
||
|
|
value, ok := h.clients.Load(cl)
|
||
|
|
if !ok || pk.FixedHeader.Retain || pk.TopicName != StatusTopic(cl.ID) || len(pk.Payload) > 2048 {
|
||
|
|
return pk, packets.ErrRejectPacket
|
||
|
|
}
|
||
|
|
var beat Heartbeat
|
||
|
|
if json.Unmarshal(pk.Payload, &beat) != nil || !ValidateHeartbeat(beat) {
|
||
|
|
return pk, packets.ErrRejectPacket
|
||
|
|
}
|
||
|
|
c := value.(connection)
|
||
|
|
ctx, cancel := bounded()
|
||
|
|
defer cancel()
|
||
|
|
tag, err := h.service.Pool.Exec(ctx, `UPDATE fleet_presence p SET connected=$3,last_seen_at=now(),firmware_version=CASE WHEN $3 THEN $4 ELSE firmware_version END,version_code=CASE WHEN $3 THEN $5 ELSE version_code END,ota_supported=CASE WHEN $3 THEN $6 ELSE ota_supported END FROM device d WHERE p.device_id=$1 AND p.connection_id=$2 AND d.id=p.device_id AND d.status='registered' AND d.activation_state NOT IN ('LOST','REVOKED')`, c.device, c.token, beat.Online, beat.Version, beat.Code, beat.OTASupported)
|
||
|
|
if err != nil || tag.RowsAffected() != 1 {
|
||
|
|
return pk, packets.ErrRejectPacket
|
||
|
|
}
|
||
|
|
return pk, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// StartBroker serves one API instance over plain MQTT/TCP.
|
||
|
|
func (s *Service) StartBroker(ctx context.Context, address string, logger *slog.Logger) (func(), error) {
|
||
|
|
if s.BrokerURL == "" {
|
||
|
|
return func() {}, nil
|
||
|
|
}
|
||
|
|
publicURL, err := url.Parse(s.BrokerURL)
|
||
|
|
if err != nil || publicURL.Scheme != "mqtt" || publicURL.Host == "" {
|
||
|
|
return nil, fmt.Errorf("DEVICE_MQTT_PUBLIC_URL must use mqtt:// with a host")
|
||
|
|
}
|
||
|
|
broker := mqtt.New(&mqtt.Options{InlineClient: true, Logger: logger})
|
||
|
|
broker.Options.Capabilities.MaximumPacketSize = 8192
|
||
|
|
if err := broker.AddHook(&fleetHook{service: s}, nil); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
err = broker.AddListener(listeners.NewTCP(listeners.Config{ID: "devices", Address: address}))
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if _, err = s.Pool.Exec(ctx, `UPDATE fleet_presence SET connected=false`); err != nil {
|
||
|
|
_ = broker.Close()
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if err = broker.Serve(); err != nil {
|
||
|
|
_ = broker.Close()
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
s.Publish = func(topic string, body []byte) error {
|
||
|
|
if ctx.Err() != nil {
|
||
|
|
return errors.New("broker stopping")
|
||
|
|
}
|
||
|
|
return broker.Publish(topic, body, false, 1)
|
||
|
|
}
|
||
|
|
go func() {
|
||
|
|
timer := time.NewTicker(5 * time.Second)
|
||
|
|
defer timer.Stop()
|
||
|
|
for {
|
||
|
|
select {
|
||
|
|
case <-ctx.Done():
|
||
|
|
return
|
||
|
|
case <-timer.C:
|
||
|
|
if e := s.Expire(ctx); e != nil {
|
||
|
|
logger.Error("OTA expiry check failed")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}()
|
||
|
|
return func() { _ = broker.Close() }, nil
|
||
|
|
}
|