Files
platforms/backend/iot-server/internal/service/service.go

266 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package service 内嵌 MQTT Broker并在内部 HTTP 边界接收待下发命令。
package service
import (
"bytes"
"context"
"crypto/subtle"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"sync/atomic"
"time"
"git.apinb.com/heqiapp/platforms/backend/iot-server/internal/config"
"git.apinb.com/heqiapp/platforms/backend/iot-server/internal/protocol"
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
broker *mqtt.Server
packet atomic.Uint32
http *http.Server
ready atomic.Bool
}
type Command struct {
Identity string `json:"identity"`
IdempotencyKey string `json:"idempotency_key"`
DeviceID string `json:"device_id"`
Action string `json:"action"`
ExpiresAt time.Time `json:"expires_at"`
KeyID byte `json:"key_id"`
DeviceKind byte `json:"device_kind"`
DeviceType byte `json:"device_type"`
DeviceModel [3]byte `json:"device_model"`
Controller byte `json:"controller"`
Loop byte `json:"loop"`
Component byte `json:"component"`
}
type Envelope struct {
Type, Topic, DeviceID, ReceivedAt string
PayloadHex string
Frame *DecodedFrame `json:"frame,omitempty"`
}
type DecodedFrame struct {
KeyID, Version, Control, MainID byte
PacketNumber uint16
Sequence byte
Final bool
DeviceTime string
PayloadHex string
Realtime *protocol.RealtimeData `json:"realtime,omitempty"`
}
func New(cfg config.Config) (*Service, error) {
keys, err := cfg.Keys()
if err != nil {
return nil, err
}
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
}
listenerConfig.TLSConfig = tlsConfig
}
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) {
roots, err := x509.SystemCertPool()
if err != nil {
roots = x509.NewCertPool()
}
if cfg.MQTT.CAFile != "" {
data, readErr := os.ReadFile(cfg.MQTT.CAFile)
if readErr != nil {
return nil, readErr
}
if !roots.AppendCertsFromPEM(data) {
return nil, fmt.Errorf("MQTT CA 证书无效")
}
}
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 err := s.broker.Subscribe(s.cfg.MQTT.UpTopic, 1, s.onMessage); err != nil {
return fmt.Errorf("订阅设备上行 Topic: %w", err)
}
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)
s.http = &http.Server{Addr: s.cfg.HTTP.Address, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
go func() {
<-ctx.Done()
shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
s.ready.Store(false)
_ = s.http.Shutdown(shutdown)
_ = s.broker.Close()
}()
err := s.http.ListenAndServe()
if err == http.ErrServerClosed {
return nil
}
return err
}
func (s *Service) health(w http.ResponseWriter, _ *http.Request) {
if !s.ready.Load() {
http.Error(w, "mqtt broker unavailable", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Service) command(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !secureEqual(r.Header.Get("X-Heqi-Iot-Token"), s.cfg.HTTP.InternalToken) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var cmd Command
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&cmd); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if cmd.Identity == "" || cmd.IdempotencyKey == "" || cmd.DeviceID == "" || time.Now().After(cmd.ExpiresAt) {
http.Error(w, "invalid or expired command", http.StatusUnprocessableEntity)
return
}
deviceID, err := decodeDeviceID(cmd.DeviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var payload []byte
switch cmd.Action {
case "open_valve":
payload, err = protocol.ValveCommand(cmd.Controller, cmd.Loop, cmd.Component, true)
case "close_valve":
payload, err = protocol.ValveCommand(cmd.Controller, cmd.Loop, cmd.Component, false)
default:
err = fmt.Errorf("unsupported action")
}
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
n := s.packet.Add(1)
frame := protocol.Frame{KeyID: cmd.KeyID, Version: 1, Control: 0x10, DeviceKind: cmd.DeviceKind, DeviceType: cmd.DeviceType, DeviceModel: cmd.DeviceModel, DeviceID: deviceID, PacketNumber: uint16(n%65535 + 1), Sequence: 1, Final: true, DeviceTime: time.Now(), Payload: payload}
raw, err := protocol.Encode(frame, s.keys)
if err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
topic := strings.ReplaceAll(s.cfg.MQTT.DownTopic, "{deviceId}", cmd.DeviceID)
if err := s.broker.Publish(topic, raw, false, s.cfg.MQTT.QoS); err != nil {
http.Error(w, "mqtt publish failed", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(map[string]any{"command_identity": cmd.Identity, "packet_number": frame.PacketNumber, "status": "dispatched"})
}
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
main := byte(0)
if len(frame.Payload) > 0 {
main = frame.Payload[0]
}
decoded := &DecodedFrame{KeyID: frame.KeyID, Version: frame.Version, Control: frame.Control, MainID: main, PacketNumber: frame.PacketNumber, Sequence: frame.Sequence, Final: frame.Final, DeviceTime: frame.DeviceTime.Format(time.RFC3339), PayloadHex: hex.EncodeToString(frame.Payload)}
if main == protocol.MainRealtime {
if realtime, parseErr := protocol.DecodeRealtime(frame.Payload); parseErr == nil {
decoded.Realtime = &realtime
}
}
envelope.Frame = decoded
}
data, _ := json.Marshal(envelope)
request, err := http.NewRequest(http.MethodPost, s.cfg.HTTP.CallbackURL, bytes.NewReader(data))
if err != nil {
return
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Heqi-Iot-Token", s.cfg.HTTP.InternalToken)
response, err := http.DefaultClient.Do(request)
if err == nil {
_ = response.Body.Close()
}
}
func decodeDeviceID(value string) ([8]byte, error) {
var result [8]byte
decoded, err := hex.DecodeString(value)
if err != nil || len(decoded) != 8 {
return result, fmt.Errorf("device_id 必须是 16 位 BCD/十六进制字符串")
}
copy(result[:], decoded)
return result, nil
}
func secureEqual(left, right string) bool {
return len(left) == len(right) && subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1
}