refactor: embed mochi mqtt broker

This commit is contained in:
2026-08-03 14:19:28 +08:00
parent fefdba470a
commit 5edca1524f
13 changed files with 148 additions and 69 deletions

View File

@@ -1,5 +1,5 @@
# IoT Server
设备侧 MQTT 适配进程。连接外部 EMQX/Mosquitto Broker订阅 `devices/+/up``devices/+/ack`按《气体探测器通讯协议》V1.8 校验、解密和解析原始二进制帧;下行命令由受保护的内部 HTTP 接口接收后发布到 `devices/{deviceId}/down`
设备侧 MQTT 接入进程。使用 `github.com/mochi-mqtt/server/v2` 内嵌实现 MQTT Broker监听 `devices/+/up``devices/+/ack`按《气体探测器通讯协议》V1.8 校验、解密和解析原始二进制帧;下行命令由受保护的内部 HTTP 接口接收后发布到 `devices/{deviceId}/down`,不依赖 Eclipse Paho 或外部 EMQX/Mosquitto
生产环境必须启用 MQTT TLS、每设备凭证并通过环境变量或密钥注入覆盖 YAML 开发占位密
YAML 的 `MQTT.Devices` 为每台设备配置独立 Client ID、设备号和凭证Broker ACL 只允许设备发布自己的 `up/ack` 并订阅自己的 `down`生产环境必须启用 MQTT TLS,并通过密钥注入覆盖 YAML 开发占位密码;单设备开发配置可用 `HEQI_IOT_MQTT_PASSWORD` 覆盖密码

View File

@@ -1,9 +1,12 @@
Service: platform-iot-server
MQTT:
Broker: tcp://127.0.0.1:1883
ClientID: heqi-iot-server-dev
Username: heqi-dev
Password: change-me
Address: 127.0.0.1:1883
ListenerID: mqtt-tcp
Devices:
- ClientID: detector-0000000000000001
DeviceID: "0000000000000001"
Username: detector-0000000000000001
Password: change-me
UpTopic: devices/+/up
DownTopic: devices/{deviceId}/down
AckTopic: devices/+/ack

View File

@@ -3,12 +3,11 @@ module git.apinb.com/heqiapp/platforms/backend/iot-server
go 1.26.1
require (
github.com/eclipse/paho.mqtt.golang v1.5.1
github.com/mochi-mqtt/server/v2 v2.7.9
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/gorilla/websocket v1.5.3 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
github.com/rs/xid v1.4.0 // indirect
)

View File

@@ -1,11 +1,9 @@
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
github.com/mochi-mqtt/server/v2 v2.7.9 h1:y0g4vrSLAag7T07l2oCzOa/+nKVLoazKEWAArwqBNYI=
github.com/mochi-mqtt/server/v2 v2.7.9/go.mod h1:lZD3j35AVNqJL5cezlnSkuG05c0FCHSsfAKSPBOSbqc=
github.com/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -11,18 +11,23 @@ import (
)
type MQTT struct {
Broker string `yaml:"Broker"`
ClientID string `yaml:"ClientID"`
Username string `yaml:"Username"`
Password string `yaml:"Password"`
UpTopic string `yaml:"UpTopic"`
DownTopic string `yaml:"DownTopic"`
AckTopic string `yaml:"AckTopic"`
QoS byte `yaml:"QoS"`
TLS bool `yaml:"TLS"`
CAFile string `yaml:"CAFile"`
CertificateFile string `yaml:"CertificateFile"`
PrivateKeyFile string `yaml:"PrivateKeyFile"`
Address string `yaml:"Address"`
ListenerID string `yaml:"ListenerID"`
Devices []MQTTDevice `yaml:"Devices"`
UpTopic string `yaml:"UpTopic"`
DownTopic string `yaml:"DownTopic"`
AckTopic string `yaml:"AckTopic"`
QoS byte `yaml:"QoS"`
TLS bool `yaml:"TLS"`
CAFile string `yaml:"CAFile"`
CertificateFile string `yaml:"CertificateFile"`
PrivateKeyFile string `yaml:"PrivateKeyFile"`
}
type MQTTDevice struct {
ClientID string `yaml:"ClientID"`
DeviceID string `yaml:"DeviceID"`
Username string `yaml:"Username"`
Password string `yaml:"Password"`
}
type HTTP struct {
Address string `yaml:"Address"`
@@ -50,13 +55,26 @@ func Load(path string) (Config, error) {
if err = yaml.Unmarshal(data, &cfg); err != nil {
return cfg, err
}
override(&cfg.MQTT.Password, "HEQI_IOT_MQTT_PASSWORD")
if len(cfg.MQTT.Devices) == 1 {
override(&cfg.MQTT.Devices[0].Password, "HEQI_IOT_MQTT_PASSWORD")
}
override(&cfg.HTTP.InternalToken, "HEQI_IOT_INTERNAL_TOKEN")
override(&cfg.Protocol.Key1, "HEQI_IOT_KEY_1")
override(&cfg.Protocol.Key2, "HEQI_IOT_KEY_2")
override(&cfg.Protocol.Key3, "HEQI_IOT_KEY_3")
if cfg.MQTT.Broker == "" || cfg.HTTP.Address == "" || cfg.HTTP.InternalToken == "" {
return cfg, fmt.Errorf("MQTT.Broker、HTTP.Address 和 HTTP.InternalToken 必填")
if cfg.MQTT.Address == "" || cfg.HTTP.Address == "" || cfg.HTTP.InternalToken == "" {
return cfg, fmt.Errorf("MQTT.Address、HTTP.Address 和 HTTP.InternalToken 必填")
}
if cfg.MQTT.ListenerID == "" {
cfg.MQTT.ListenerID = "mqtt-tcp"
}
if len(cfg.MQTT.Devices) == 0 {
return cfg, fmt.Errorf("MQTT.Devices 必填Broker 禁止匿名连接")
}
for index, device := range cfg.MQTT.Devices {
if device.ClientID == "" || device.DeviceID == "" || device.Username == "" || device.Password == "" {
return cfg, fmt.Errorf("MQTT.Devices[%d] 的 ClientID、DeviceID、Username 和 Password 必填", index)
}
}
return cfg, nil
}

View File

@@ -0,0 +1,34 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadRequiresDeviceCredentials(t *testing.T) {
path := filepath.Join(t.TempDir(), "iot.yaml")
data := []byte("MQTT:\n Address: 127.0.0.1:1883\nHTTP:\n Address: 127.0.0.1:12428\n InternalToken: test\n")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
if _, err := Load(path); err == nil {
t.Fatal("未配置设备凭证时应拒绝启动")
}
}
func TestLoadDefaultsListenerAndOverridesSingleDevicePassword(t *testing.T) {
t.Setenv("HEQI_IOT_MQTT_PASSWORD", "from-secret-store")
path := filepath.Join(t.TempDir(), "iot.yaml")
data := []byte("MQTT:\n Address: 127.0.0.1:1883\n Devices:\n - ClientID: detector-1\n DeviceID: '0000000000000001'\n Username: detector-1\n Password: placeholder\nHTTP:\n Address: 127.0.0.1:12428\n InternalToken: test\n")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if cfg.MQTT.ListenerID != "mqtt-tcp" || cfg.MQTT.Devices[0].Password != "from-secret-store" {
t.Fatalf("unexpected MQTT config: %+v", cfg.MQTT)
}
}

View File

@@ -1,4 +1,4 @@
// Package service 连接外部 MQTT Broker并在内部 HTTP 边界接收待下发命令。
// Package service 内嵌 MQTT Broker并在内部 HTTP 边界接收待下发命令。
package service
import (
@@ -19,15 +19,19 @@ import (
"git.apinb.com/heqiapp/platforms/backend/iot-server/internal/config"
"git.apinb.com/heqiapp/platforms/backend/iot-server/internal/protocol"
mqtt "github.com/eclipse/paho.mqtt.golang"
mqtt "github.com/mochi-mqtt/server/v2"
"github.com/mochi-mqtt/server/v2/hooks/auth"
"github.com/mochi-mqtt/server/v2/listeners"
"github.com/mochi-mqtt/server/v2/packets"
)
type Service struct {
cfg config.Config
keys protocol.Keyring
mqtt mqtt.Client
broker *mqtt.Server
packet atomic.Uint32
http *http.Server
ready atomic.Bool
}
type Command struct {
Identity string `json:"identity"`
@@ -63,16 +67,37 @@ func New(cfg config.Config) (*Service, error) {
if err != nil {
return nil, err
}
options := mqtt.NewClientOptions().AddBroker(cfg.MQTT.Broker).SetClientID(cfg.MQTT.ClientID).SetUsername(cfg.MQTT.Username).SetPassword(cfg.MQTT.Password).SetAutoReconnect(true).SetConnectRetry(true)
broker := mqtt.New(&mqtt.Options{InlineClient: true})
ledger := &auth.Ledger{}
for _, device := range cfg.MQTT.Devices {
ledger.Auth = append(ledger.Auth, auth.AuthRule{
Client: auth.RString(device.ClientID), Username: auth.RString(device.Username),
Password: auth.RString(device.Password), Allow: true,
})
ledger.ACL = append(ledger.ACL, auth.ACLRule{
Client: auth.RString(device.ClientID), Username: auth.RString(device.Username),
Filters: auth.Filters{
auth.RString(strings.ReplaceAll(cfg.MQTT.UpTopic, "+", device.DeviceID)): auth.WriteOnly,
auth.RString(strings.ReplaceAll(cfg.MQTT.AckTopic, "+", device.DeviceID)): auth.WriteOnly,
auth.RString(strings.ReplaceAll(cfg.MQTT.DownTopic, "{deviceId}", device.DeviceID)): auth.ReadOnly,
},
})
}
if err = broker.AddHook(new(auth.Hook), &auth.Options{Ledger: ledger}); err != nil {
return nil, fmt.Errorf("配置 MQTT 认证: %w", err)
}
listenerConfig := listeners.Config{ID: cfg.MQTT.ListenerID, Address: cfg.MQTT.Address}
if cfg.MQTT.TLS {
tlsConfig, tlsErr := makeTLSConfig(cfg)
if tlsErr != nil {
return nil, tlsErr
}
options.SetTLSConfig(tlsConfig)
listenerConfig.TLSConfig = tlsConfig
}
client := mqtt.NewClient(options)
return &Service{cfg: cfg, keys: keys, mqtt: client}, nil
if err = broker.AddListener(listeners.NewTCP(listenerConfig)); err != nil {
return nil, fmt.Errorf("配置 MQTT 监听器: %w", err)
}
return &Service{cfg: cfg, keys: keys, broker: broker}, nil
}
func makeTLSConfig(cfg config.Config) (*tls.Config, error) {
@@ -89,28 +114,32 @@ func makeTLSConfig(cfg config.Config) (*tls.Config, error) {
return nil, fmt.Errorf("MQTT CA 证书无效")
}
}
result := &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots}
if cfg.MQTT.CertificateFile != "" || cfg.MQTT.PrivateKeyFile != "" {
certificate, loadErr := tls.LoadX509KeyPair(cfg.MQTT.CertificateFile, cfg.MQTT.PrivateKeyFile)
if loadErr != nil {
return nil, loadErr
}
result.Certificates = []tls.Certificate{certificate}
if cfg.MQTT.CertificateFile == "" || cfg.MQTT.PrivateKeyFile == "" {
return nil, fmt.Errorf("启用 MQTT TLS 时 CertificateFile 和 PrivateKeyFile 必填")
}
certificate, err := tls.LoadX509KeyPair(cfg.MQTT.CertificateFile, cfg.MQTT.PrivateKeyFile)
if err != nil {
return nil, err
}
result := &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{certificate}}
if cfg.MQTT.CAFile != "" {
result.ClientCAs = roots
result.ClientAuth = tls.RequireAndVerifyClientCert
}
return result, nil
}
func (s *Service) Run(ctx context.Context) error {
if token := s.mqtt.Connect(); !token.WaitTimeout(15 * time.Second) {
return fmt.Errorf("MQTT 连接超时")
} else if token.Error() != nil {
return token.Error()
if err := s.broker.Subscribe(s.cfg.MQTT.UpTopic, 1, s.onMessage); err != nil {
return fmt.Errorf("订阅设备上行 Topic: %w", err)
}
for _, topic := range []string{s.cfg.MQTT.UpTopic, s.cfg.MQTT.AckTopic} {
if token := s.mqtt.Subscribe(topic, s.cfg.MQTT.QoS, s.onMessage); token.Wait() && token.Error() != nil {
return token.Error()
}
if err := s.broker.Subscribe(s.cfg.MQTT.AckTopic, 2, s.onMessage); err != nil {
return fmt.Errorf("订阅设备回执 Topic: %w", err)
}
if err := s.broker.Serve(); err != nil {
return fmt.Errorf("启动 MQTT Broker: %w", err)
}
s.ready.Store(true)
mux := http.NewServeMux()
mux.HandleFunc("/health", s.health)
mux.HandleFunc("/internal/v1/commands", s.command)
@@ -119,8 +148,9 @@ func (s *Service) Run(ctx context.Context) error {
<-ctx.Done()
shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
s.ready.Store(false)
_ = s.http.Shutdown(shutdown)
s.mqtt.Disconnect(250)
_ = s.broker.Close()
}()
err := s.http.ListenAndServe()
if err == http.ErrServerClosed {
@@ -130,8 +160,8 @@ func (s *Service) Run(ctx context.Context) error {
}
func (s *Service) health(w http.ResponseWriter, _ *http.Request) {
if !s.mqtt.IsConnectionOpen() {
http.Error(w, "mqtt disconnected", http.StatusServiceUnavailable)
if !s.ready.Load() {
http.Error(w, "mqtt broker unavailable", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -181,8 +211,7 @@ func (s *Service) command(w http.ResponseWriter, r *http.Request) {
return
}
topic := strings.ReplaceAll(s.cfg.MQTT.DownTopic, "{deviceId}", cmd.DeviceID)
token := s.mqtt.Publish(topic, s.cfg.MQTT.QoS, false, raw)
if !token.WaitTimeout(10*time.Second) || token.Error() != nil {
if err := s.broker.Publish(topic, raw, false, s.cfg.MQTT.QoS); err != nil {
http.Error(w, "mqtt publish failed", http.StatusBadGateway)
return
}
@@ -191,9 +220,9 @@ func (s *Service) command(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"command_identity": cmd.Identity, "packet_number": frame.PacketNumber, "status": "dispatched"})
}
func (s *Service) onMessage(_ mqtt.Client, message mqtt.Message) {
raw := append([]byte(nil), message.Payload()...)
envelope := Envelope{Type: "device_message", Topic: message.Topic(), ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano), PayloadHex: hex.EncodeToString(raw)}
func (s *Service) onMessage(_ *mqtt.Client, _ packets.Subscription, message packets.Packet) {
raw := append([]byte(nil), message.Payload...)
envelope := Envelope{Type: "device_message", Topic: message.TopicName, ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano), PayloadHex: hex.EncodeToString(raw)}
if frame, err := protocol.Decode(raw, s.keys); err == nil {
deviceID := hex.EncodeToString(frame.DeviceID[:])
envelope.DeviceID = deviceID