feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
package devicefleet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
downloadTokenBytes = 16
|
||||
downloadTicketTTL = 30 * time.Minute
|
||||
)
|
||||
|
||||
var ErrDownloadTicketUnavailable = errors.New("firmware download ticket is unavailable")
|
||||
|
||||
type DownloadGrant struct {
|
||||
ObjectRef string
|
||||
Size int64
|
||||
SHA256 string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func newDownloadTicket(baseURL string) (string, []byte, string, time.Time, error) {
|
||||
raw := make([]byte, downloadTokenBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", nil, "", time.Time{}, err
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(raw)
|
||||
digest := sha256.Sum256([]byte(token))
|
||||
address, err := shortDownloadURL(baseURL, token)
|
||||
if err != nil {
|
||||
return "", nil, "", time.Time{}, err
|
||||
}
|
||||
return token, digest[:], address, time.Now().UTC().Add(downloadTicketTTL), nil
|
||||
}
|
||||
|
||||
func shortDownloadURL(baseURL, token string) (string, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(baseURL))
|
||||
if err != nil || parsed.Host == "" || parsed.User != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return "", errors.New("firmware download base URL must be an HTTP(S) origin")
|
||||
}
|
||||
parsed.Path = "/f/" + token
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func downloadTokenHash(token string) ([]byte, error) {
|
||||
raw, err := base64.RawURLEncoding.DecodeString(token)
|
||||
if err != nil || len(raw) != downloadTokenBytes || base64.RawURLEncoding.EncodeToString(raw) != token {
|
||||
return nil, ErrDownloadTicketUnavailable
|
||||
}
|
||||
digest := sha256.Sum256([]byte(token))
|
||||
return digest[:], nil
|
||||
}
|
||||
|
||||
func (s *Service) ResolveDownload(ctx context.Context, token string) (DownloadGrant, error) {
|
||||
var grant DownloadGrant
|
||||
digest, err := downloadTokenHash(strings.TrimSpace(token))
|
||||
if err != nil {
|
||||
return grant, err
|
||||
}
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
SELECT f.object_ref,f.size,f.sha256,f.created_at,t.expires_at
|
||||
FROM fleet_firmware_download_ticket t
|
||||
JOIN fleet_firmware f ON f.id=t.release_id
|
||||
LEFT JOIN fleet_ota o ON o.id=t.ota_id
|
||||
WHERE t.token_hash=$1
|
||||
AND t.revoked_at IS NULL
|
||||
AND t.expires_at>now()
|
||||
AND (t.ota_id IS NULL OR
|
||||
(o.status='pending' AND o.created_at>now()-interval '30 seconds') OR
|
||||
(o.status='accepted' AND o.created_at>now()-interval '20 minutes'))
|
||||
AND f.status='READY'`, digest).Scan(&grant.ObjectRef, &grant.Size, &grant.SHA256, &grant.CreatedAt, &grant.ExpiresAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return DownloadGrant{}, ErrDownloadTicketUnavailable
|
||||
}
|
||||
return grant, err
|
||||
}
|
||||
|
||||
func (s *Service) OpenDownload(ctx context.Context, grant DownloadGrant) (io.ReadSeekCloser, error) {
|
||||
if s.Store == nil || grant.ObjectRef == "" {
|
||||
return nil, ErrDownloadTicketUnavailable
|
||||
}
|
||||
return s.Store.OpenDownload(ctx, grant.ObjectRef)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package devicefleet
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewDownloadTicketIsCompactAndCanonical(t *testing.T) {
|
||||
token, digest, address, expiresAt, err := newDownloadTicket("https://82.157.201.114")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(token) != 22 || len(digest) != 32 || len(address) > 64 || address != "https://82.157.201.114/f/"+token {
|
||||
t.Fatalf("token=%q digest=%d address=%q", token, len(digest), address)
|
||||
}
|
||||
if expiresAt.IsZero() {
|
||||
t.Fatal("ticket expiry was not set")
|
||||
}
|
||||
if _, err = downloadTokenHash(token); err != nil {
|
||||
t.Fatal("generated token was rejected", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadTicketRejectsMalformedInput(t *testing.T) {
|
||||
for _, token := range []string{"", "not-base64!", strings.Repeat("a", 21), strings.Repeat("a", 23), strings.Repeat("A", 24) + "=="} {
|
||||
if _, err := downloadTokenHash(token); !errors.Is(err, ErrDownloadTicketUnavailable) {
|
||||
t.Fatalf("token %q returned %v", token, err)
|
||||
}
|
||||
}
|
||||
if _, err := shortDownloadURL("ftp://example.test", strings.Repeat("a", 22)); err == nil {
|
||||
t.Fatal("non-HTTP download origin accepted")
|
||||
}
|
||||
if _, err := shortDownloadURL("https://example.test/base", strings.Repeat("a", 22)); err == nil {
|
||||
t.Fatal("download origin with a path accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package devicefleet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Start authorizes under the caller's device-twin lock, shared with binding changes.
|
||||
func (s *Service) Start(ctx context.Context, request string, code int64, authorize func(pgx.Tx) (string, error)) (Task, error) {
|
||||
var result Task
|
||||
if s.Publish == nil {
|
||||
return result, conflict("MQTT_UNAVAILABLE", "MQTT服务未启用")
|
||||
}
|
||||
if _, err := uuid.Parse(request); err != nil {
|
||||
return result, conflict("REQUEST_ID_REQUIRED", "请求号必须为UUID")
|
||||
}
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
device, err := authorize(tx)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
err = tx.QueryRow(ctx, `SELECT o.id::text,o.status,f.version,f.version_code,o.message,o.created_at FROM fleet_ota o JOIN fleet_firmware f ON f.id=o.release_id WHERE o.request_id=$1 AND o.device_id=$2`, request, device).Scan(&result.ID, &result.Status, &result.Version, &result.Code, &result.Message, &result.Created)
|
||||
if err == nil {
|
||||
if result.Code != code {
|
||||
return result, conflict("REQUEST_CONFLICT", "同一请求号不能更换目标版本")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return result, err
|
||||
}
|
||||
var tenant, model, factory string
|
||||
var online, supported, busy bool
|
||||
var current int64
|
||||
err = tx.QueryRow(ctx, `SELECT d.tenant_id::text,d.model,d.factory_device_id,COALESCE(p.connected AND p.last_seen_at>now()-interval '90 seconds',false),COALESCE(p.ota_supported,false),COALESCE(p.version_code,0),EXISTS(SELECT 1 FROM call_session WHERE device_id=d.id AND call_state IN ('PREPARING','STARTING','TALKING','STOPPING')) OR EXISTS(SELECT 1 FROM fleet_ota WHERE device_id=d.id AND status IN ('pending','accepted')) FROM device d LEFT JOIN fleet_presence p ON p.device_id=d.id WHERE d.id=$1 AND d.status='registered' AND d.activation_state NOT IN ('LOST','REVOKED')`, device).Scan(&tenant, &model, &factory, &online, &supported, ¤t, &busy)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if !online {
|
||||
return result, conflict("DEVICE_OFFLINE", "设备离线,请开机联网后重试")
|
||||
}
|
||||
var otherBusy bool
|
||||
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM conversation WHERE device_id=$1 AND status NOT IN ('COMPLETED','FAILED','EXPIRED')) OR EXISTS(SELECT 1 FROM device_command WHERE device_id=$1 AND status IN ('QUEUED','DELIVERED','ACKNOWLEDGED') AND expires_at>now()) OR EXISTS(SELECT 1 FROM firmware_assignment WHERE device_id=$1 AND status IN ('ASSIGNED','DOWNLOADING','INSTALLING'))`, device).Scan(&otherBusy)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
busy = busy || otherBusy
|
||||
if !supported {
|
||||
return result, conflict("OTA_UNSUPPORTED", "当前设备固件或分区不支持OTA,请先由嵌入式端适配")
|
||||
}
|
||||
if busy {
|
||||
return result, conflict("DEVICE_BUSY", "设备正在通话或升级,请稍后重试")
|
||||
}
|
||||
release, err := scanRelease(tx.QueryRow(ctx, `SELECT `+releaseFields+` FROM fleet_firmware WHERE tenant_id=$1 AND model=$2 AND status='READY' ORDER BY version_code DESC LIMIT 1`, tenant, model))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if release.Code != code || code <= current {
|
||||
return result, conflict("VERSION_CHANGED", "版本已变化或已经是最新版本,请刷新")
|
||||
}
|
||||
_, tokenHash, downloadURL, downloadExpiresAt, err := newDownloadTicket(s.DownloadBaseURL)
|
||||
if err != nil {
|
||||
return result, conflict("DOWNLOAD_UNAVAILABLE", "固件短下载地址未正确配置")
|
||||
}
|
||||
result = Task{ID: uuid.NewString(), Status: "pending", Version: release.Version, Code: code, Created: time.Now().UTC()}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO fleet_ota(id,device_id,release_id,request_id) VALUES($1,$2,$3,$4)`, result.ID, device, release.ID, request)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO fleet_firmware_download_ticket(token_hash,release_id,ota_id,expires_at) VALUES($1,$2,$3,$4)`, tokenHash, release.ID, result.ID, downloadExpiresAt)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return result, err
|
||||
}
|
||||
command, _ := json.Marshal(map[string]any{"task_id": result.ID, "model": model, "version": release.Version, "version_code": code, "download_url": downloadURL, "sha256": release.SHA256, "size": release.Size, "expires_at_ms": result.Created.Add(30 * time.Second).UnixMilli()})
|
||||
// No retained commands: a device reconnecting later must not begin an obsolete OTA.
|
||||
if err = s.Publish(OTATopic(factory), command); err != nil {
|
||||
_, _ = s.Pool.Exec(ctx, `UPDATE fleet_ota SET status='failed',message='升级指令发送失败',updated_at=now() WHERE id=$1 AND status='pending'`, result.ID)
|
||||
_, _ = s.Pool.Exec(ctx, `UPDATE fleet_firmware_download_ticket SET revoked_at=now() WHERE ota_id=$1 AND revoked_at IS NULL`, result.ID)
|
||||
return result, conflict("MQTT_PUBLISH_FAILED", "升级指令发送失败,请刷新后重试")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (s *Service) Expire(ctx context.Context) error {
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
_, err = tx.Exec(ctx, `UPDATE fleet_ota SET status='timeout',message=CASE WHEN status='pending' THEN '设备未确认接收指令' ELSE '升级结果超时,请检查设备' END,updated_at=now() WHERE (status='pending' AND created_at<now()-interval '30 seconds') OR (status='accepted' AND created_at<now()-interval '20 minutes')`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE fleet_firmware_download_ticket t SET revoked_at=now() FROM fleet_ota o WHERE o.id=t.ota_id AND o.status='timeout' AND t.revoked_at IS NULL`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// Manifest resolves the MQTT wakeup over authenticated HTTPS. The device must
|
||||
// not install a URL or digest taken solely from the plaintext MQTT transport.
|
||||
func (s *Service) Manifest(ctx context.Context, deviceID, taskID string) (map[string]any, error) {
|
||||
if _, err := uuid.Parse(taskID); err != nil {
|
||||
return nil, conflict("INVALID_OTA_TASK", "升级任务编号不正确")
|
||||
}
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var releaseID, model, version, digest string
|
||||
var size, code int64
|
||||
var created time.Time
|
||||
err = tx.QueryRow(ctx, `SELECT f.id::text,f.model,f.version,f.version_code,f.size,f.sha256,o.created_at
|
||||
FROM fleet_ota o JOIN fleet_firmware f ON f.id=o.release_id
|
||||
JOIN device d ON d.id=o.device_id AND d.tenant_id=f.tenant_id
|
||||
WHERE o.id=$1 AND o.device_id=$2 AND d.status='registered' AND d.activation_state NOT IN ('LOST','REVOKED')
|
||||
AND f.status='READY' AND f.model=d.model AND
|
||||
((o.status='pending' AND o.created_at>now()-interval '30 seconds') OR
|
||||
(o.status='accepted' AND o.created_at>now()-interval '20 minutes')) FOR UPDATE OF o`, taskID, deviceID).
|
||||
Scan(&releaseID, &model, &version, &code, &size, &digest, &created)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, conflict("OTA_TASK_UNAVAILABLE", "升级任务不可用或已过期")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, hash, url, expires, err := newDownloadTicket(s.DownloadBaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO fleet_firmware_download_ticket(token_hash,release_id,ota_id,expires_at) VALUES($1,$2,$3,$4)
|
||||
ON CONFLICT (ota_id) WHERE ota_id IS NOT NULL DO UPDATE SET token_hash=EXCLUDED.token_hash,expires_at=EXCLUDED.expires_at,revoked_at=NULL`, hash, releaseID, taskID, expires)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"task_id": taskID, "model": model, "version": version, "version_code": code, "size": size, "sha256": digest, "download_url": url, "expires_at_ms": created.Add(30 * time.Second).UnixMilli()}, nil
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package devicefleet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type otaDeadlineFixture struct {
|
||||
t *testing.T
|
||||
ctx context.Context
|
||||
service *Service
|
||||
release Release
|
||||
device string
|
||||
}
|
||||
|
||||
func newOTADeadlineFixture(t *testing.T) *otaDeadlineFixture {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("isolated integration database required")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
must(t, err)
|
||||
t.Cleanup(pool.Close)
|
||||
const device = "10000000-0000-0000-0000-000000000501"
|
||||
const tenant = "10000000-0000-0000-0000-000000000001"
|
||||
_, err = pool.Exec(ctx, `UPDATE device SET factory_device_id=$2 WHERE id=$1`, device, "KP01-020000001235")
|
||||
must(t, err)
|
||||
store := &fakeStore{objects: map[string][]byte{}}
|
||||
s := &Service{Pool: pool, Store: store, DownloadBaseURL: "https://fleet-fixture.invalid", Publish: func(string, []byte) error { return nil }}
|
||||
content := []byte("synthetic deadline fixture firmware")
|
||||
digest := sha256.Sum256(content)
|
||||
r, err := s.Prepare(ctx, tenant, ReleaseInput{Model: "simulated-terminal", Version: "deadline-" + uuid.NewString(), Size: int64(len(content)), SHA256: hex.EncodeToString(digest[:])})
|
||||
must(t, err)
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM fleet_ota WHERE release_id=$1`, r.ID)
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM fleet_firmware WHERE id=$1`, r.ID)
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM fleet_presence WHERE device_id=$1`, device)
|
||||
_, _ = pool.Exec(context.Background(), `UPDATE device SET factory_device_id=NULL WHERE id=$1`, device)
|
||||
})
|
||||
store.objects[r.Object] = content
|
||||
r, err = s.Finalize(ctx, tenant, r.ID)
|
||||
must(t, err)
|
||||
_, err = pool.Exec(ctx, `INSERT INTO fleet_presence(device_id,password_hash,connected,last_seen_at,ota_supported) VALUES($1,$2,true,now(),true) ON CONFLICT(device_id) DO UPDATE SET connected=true,last_seen_at=now(),ota_supported=true,version_code=0`, device, make([]byte, 32))
|
||||
must(t, err)
|
||||
return &otaDeadlineFixture{t: t, ctx: ctx, service: s, release: r, device: device}
|
||||
}
|
||||
|
||||
func (f *otaDeadlineFixture) task(status string, age time.Duration) (string, string) {
|
||||
f.t.Helper()
|
||||
id := uuid.NewString()
|
||||
_, err := f.service.Pool.Exec(f.ctx, `INSERT INTO fleet_ota(id,device_id,release_id,request_id,status,created_at) VALUES($1,$2,$3,$4,$5,now()-$6::interval)`, id, f.device, f.release.ID, uuid.NewString(), status, fmt.Sprintf("%f seconds", age.Seconds()))
|
||||
must(f.t, err)
|
||||
token, hash, _, expires, err := newDownloadTicket(f.service.DownloadBaseURL)
|
||||
must(f.t, err)
|
||||
_, err = f.service.Pool.Exec(f.ctx, `INSERT INTO fleet_firmware_download_ticket(token_hash,release_id,ota_id,expires_at) VALUES($1,$2,$3,$4)`, hash, f.release.ID, id, expires)
|
||||
must(f.t, err)
|
||||
return id, token
|
||||
}
|
||||
|
||||
func (f *otaDeadlineFixture) expectState(task, status string) {
|
||||
f.t.Helper()
|
||||
state, err := f.service.State(f.ctx, f.device)
|
||||
must(f.t, err)
|
||||
if state.Task == nil || state.Task.ID != task || state.Task.Status != status {
|
||||
f.t.Fatalf("read model expected %s/%s, got %+v", task, status, state.Task)
|
||||
}
|
||||
}
|
||||
|
||||
func expectOTAError(t *testing.T, err error, code string) {
|
||||
t.Helper()
|
||||
var fleetErr *Error
|
||||
if !errors.As(err, &fleetErr) || fleetErr.Code != code {
|
||||
t.Fatalf("expected %s, got %v", code, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFleetOTAAcceptedBeforeTwentyMinuteBoundary(t *testing.T) {
|
||||
f := newOTADeadlineFixture(t)
|
||||
id, _ := f.task("accepted", 20*time.Minute-time.Second)
|
||||
manifest, err := f.service.Manifest(f.ctx, f.device, id)
|
||||
must(t, err)
|
||||
token := strings.TrimPrefix(manifest["download_url"].(string), "https://fleet-fixture.invalid/f/")
|
||||
_, err = f.service.ResolveDownload(f.ctx, token)
|
||||
must(t, err)
|
||||
must(t, f.service.Expire(f.ctx))
|
||||
f.expectState(id, "accepted")
|
||||
// Retrying acceptance is idempotent and must not reset created_at or the budget.
|
||||
must(t, f.service.Report(f.ctx, f.device, Report{TaskID: id, Status: "accepted"}))
|
||||
must(t, f.service.Report(f.ctx, f.device, Report{TaskID: id, Status: "success", FirmwareVersion: f.release.Version, Code: f.release.Code}))
|
||||
f.expectState(id, "success")
|
||||
state, err := f.service.State(f.ctx, f.device)
|
||||
must(t, err)
|
||||
if state.CurrentCode != f.release.Code || state.CurrentVersion != f.release.Version {
|
||||
t.Fatal("success did not update read model version")
|
||||
}
|
||||
_, err = f.service.ResolveDownload(f.ctx, token)
|
||||
if !errors.Is(err, ErrDownloadTicketUnavailable) {
|
||||
t.Fatalf("terminal ticket remained usable: %v", err)
|
||||
}
|
||||
_, err = f.service.Manifest(f.ctx, f.device, id)
|
||||
expectOTAError(t, err, "OTA_TASK_UNAVAILABLE")
|
||||
must(t, f.service.Report(f.ctx, f.device, Report{TaskID: id, Status: "success", FirmwareVersion: f.release.Version, Code: f.release.Code}))
|
||||
expectOTAError(t, f.service.Report(f.ctx, f.device, Report{TaskID: id, Status: "failed"}), "OTA_FINISHED")
|
||||
}
|
||||
|
||||
func TestFleetOTAAcceptedAfterTwentyMinuteBoundary(t *testing.T) {
|
||||
for _, result := range []string{"success", "failed"} {
|
||||
t.Run(result, func(t *testing.T) {
|
||||
f := newOTADeadlineFixture(t)
|
||||
id, token := f.task("accepted", 20*time.Minute+time.Second)
|
||||
_, err := f.service.Manifest(f.ctx, f.device, id)
|
||||
expectOTAError(t, err, "OTA_TASK_UNAVAILABLE")
|
||||
// The periodic sweeper has not run: access still rejects an aged active row.
|
||||
_, err = f.service.ResolveDownload(f.ctx, token)
|
||||
if !errors.Is(err, ErrDownloadTicketUnavailable) {
|
||||
t.Fatalf("expired active row authorized download: %v", err)
|
||||
}
|
||||
f.expectState(id, "accepted")
|
||||
must(t, f.service.Expire(f.ctx))
|
||||
f.expectState(id, "timeout")
|
||||
var revoked bool
|
||||
must(t, f.service.Pool.QueryRow(f.ctx, `SELECT revoked_at IS NOT NULL FROM fleet_firmware_download_ticket WHERE ota_id=$1`, id).Scan(&revoked))
|
||||
if !revoked {
|
||||
t.Fatal("expiry failed to revoke ticket")
|
||||
}
|
||||
expectOTAError(t, f.service.Report(f.ctx, f.device, Report{TaskID: id, Status: "accepted"}), "OTA_EXPIRED")
|
||||
expectOTAError(t, f.service.Report(f.ctx, f.device, Report{TaskID: id, Status: "success", FirmwareVersion: "wrong", Code: f.release.Code}), "VERSION_MISMATCH")
|
||||
// Preserve existing authenticated late-result correction after timeout.
|
||||
must(t, f.service.Report(f.ctx, f.device, Report{TaskID: id, Status: result, FirmwareVersion: f.release.Version, Code: f.release.Code}))
|
||||
f.expectState(id, result)
|
||||
_, err = f.service.ResolveDownload(f.ctx, token)
|
||||
if !errors.Is(err, ErrDownloadTicketUnavailable) {
|
||||
t.Fatalf("late terminal result reenabled ticket: %v", err)
|
||||
}
|
||||
_, err = f.service.Manifest(f.ctx, f.device, id)
|
||||
expectOTAError(t, err, "OTA_TASK_UNAVAILABLE")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFleetOTAPendingDeadlineRemainsThirtySeconds(t *testing.T) {
|
||||
for _, age := range []time.Duration{29 * time.Second, 31 * time.Second} {
|
||||
t.Run(age.String(), func(t *testing.T) {
|
||||
f := newOTADeadlineFixture(t)
|
||||
id, token := f.task("pending", age)
|
||||
_, manifestErr := f.service.Manifest(f.ctx, f.device, id)
|
||||
// Manifest rotates the ticket only for the unexpired task.
|
||||
if age < 30*time.Second {
|
||||
must(t, manifestErr)
|
||||
must(t, f.service.Expire(f.ctx))
|
||||
f.expectState(id, "pending")
|
||||
must(t, f.service.Report(f.ctx, f.device, Report{TaskID: id, Status: "accepted"}))
|
||||
f.expectState(id, "accepted")
|
||||
} else {
|
||||
expectOTAError(t, manifestErr, "OTA_TASK_UNAVAILABLE")
|
||||
_, err := f.service.ResolveDownload(f.ctx, token)
|
||||
if !errors.Is(err, ErrDownloadTicketUnavailable) {
|
||||
t.Fatalf("expired pending task authorized download: %v", err)
|
||||
}
|
||||
expectOTAError(t, f.service.Report(f.ctx, f.device, Report{TaskID: id, Status: "accepted"}), "OTA_EXPIRED")
|
||||
must(t, f.service.Expire(f.ctx))
|
||||
f.expectState(id, "timeout")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFleetOTAWakeupAndDownloadTicketTTLsRemainUnchanged(t *testing.T) {
|
||||
f := newOTADeadlineFixture(t)
|
||||
var command map[string]any
|
||||
f.service.Publish = func(_ string, payload []byte) error { return json.Unmarshal(payload, &command) }
|
||||
task, err := f.service.Start(f.ctx, uuid.NewString(), f.release.Code, func(tx pgx.Tx) (string, error) {
|
||||
var device string
|
||||
err := tx.QueryRow(f.ctx, `SELECT device_id::text FROM device_twin WHERE device_id=$1 FOR UPDATE`, f.device).Scan(&device)
|
||||
return device, err
|
||||
})
|
||||
must(t, err)
|
||||
if int64(command["expires_at_ms"].(float64)) != task.Created.Add(30*time.Second).UnixMilli() {
|
||||
t.Fatal("MQTT wakeup TTL changed")
|
||||
}
|
||||
var ttlSeconds float64
|
||||
must(t, f.service.Pool.QueryRow(f.ctx, `SELECT EXTRACT(EPOCH FROM (expires_at-created_at)) FROM fleet_firmware_download_ticket WHERE ota_id=$1`, task.ID).Scan(&ttlSeconds))
|
||||
if ttlSeconds < 1799 || ttlSeconds > 1801 {
|
||||
t.Fatalf("OTA ticket TTL changed: %.3fs", ttlSeconds)
|
||||
}
|
||||
admin, err := f.service.Download(f.ctx, "10000000-0000-0000-0000-000000000001", f.release.ID)
|
||||
must(t, err)
|
||||
token := strings.TrimPrefix(admin.DownloadURL, "https://fleet-fixture.invalid/f/")
|
||||
_, err = f.service.ResolveDownload(f.ctx, token)
|
||||
must(t, err)
|
||||
digest, err := downloadTokenHash(token)
|
||||
must(t, err)
|
||||
_, err = f.service.Pool.Exec(f.ctx, `UPDATE fleet_firmware_download_ticket SET expires_at=now()-interval '1 second' WHERE token_hash=$1`, digest)
|
||||
must(t, err)
|
||||
_, err = f.service.ResolveDownload(f.ctx, token)
|
||||
if !errors.Is(err, ErrDownloadTicketUnavailable) {
|
||||
t.Fatalf("expired administrator ticket was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
// Package devicefleet implements the device MQTT heartbeat and explicit OTA workflow.
|
||||
package devicefleet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Error struct{ Code, Message string }
|
||||
|
||||
func (e *Error) Error() string { return e.Message }
|
||||
func conflict(code, message string) error { return &Error{code, message} }
|
||||
|
||||
type Store interface {
|
||||
FreezeFirmware(context.Context, string, string, string, int64) error
|
||||
OpenDownload(context.Context, string) (io.ReadSeekCloser, error)
|
||||
PresignUpload(context.Context, string, time.Duration) (string, error)
|
||||
PresignMediaDownload(context.Context, string, time.Duration, string, string, bool) (string, error)
|
||||
VerifyDigest(context.Context, string, string, int64) error
|
||||
}
|
||||
type Service struct {
|
||||
Pool *pgxpool.Pool
|
||||
Store Store
|
||||
BrokerURL string
|
||||
DownloadBaseURL string
|
||||
Publish func(string, []byte) error
|
||||
}
|
||||
type Release struct {
|
||||
ID string `json:"id"`
|
||||
Model string `json:"model"`
|
||||
Version string `json:"version"`
|
||||
Code int64 `json:"version_code"`
|
||||
Notes string `json:"release_notes"`
|
||||
Size int64 `json:"size"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Status string `json:"status"`
|
||||
Created time.Time `json:"created_at"`
|
||||
UploadURL string `json:"upload_url,omitempty"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
Object string `json:"-"`
|
||||
}
|
||||
type ReleaseInput struct {
|
||||
Model string `json:"model"`
|
||||
Version string `json:"version"`
|
||||
Notes string `json:"release_notes"`
|
||||
Size int64 `json:"size"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
const releaseFields = `id::text,model,version,version_code,release_notes,size,sha256,status,created_at,object_ref`
|
||||
|
||||
func scanRelease(row interface{ Scan(...any) error }) (r Release, err error) {
|
||||
err = row.Scan(&r.ID, &r.Model, &r.Version, &r.Code, &r.Notes, &r.Size, &r.SHA256, &r.Status, &r.Created, &r.Object)
|
||||
return
|
||||
}
|
||||
func (s *Service) Releases(ctx context.Context, tenant string) ([]Release, error) {
|
||||
rows, err := s.Pool.Query(ctx, `SELECT `+releaseFields+` FROM fleet_firmware WHERE tenant_id=$1 ORDER BY version_code DESC LIMIT 200`, tenant)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []Release{}
|
||||
for rows.Next() {
|
||||
r, e := scanRelease(rows)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
result = append(result, r)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
func (s *Service) Models(ctx context.Context, tenant string) ([]string, error) {
|
||||
rows, err := s.Pool.Query(ctx, `SELECT DISTINCT model FROM device WHERE tenant_id=$1 ORDER BY model`, tenant)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []string{}
|
||||
for rows.Next() {
|
||||
var m string
|
||||
if err = rows.Scan(&m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, m)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
func (s *Service) Prepare(ctx context.Context, tenant string, in ReleaseInput) (Release, error) {
|
||||
in.Model = strings.TrimSpace(in.Model)
|
||||
in.Version = strings.TrimSpace(in.Version)
|
||||
in.SHA256 = strings.ToLower(in.SHA256)
|
||||
hash, err := hex.DecodeString(in.SHA256)
|
||||
if err != nil || len(hash) != 32 || in.Model == "" || len(in.Model) > 120 || in.Version == "" || len(in.Version) > 80 || len(in.Notes) > 8000 || in.Size <= 0 || in.Size > 32<<20 {
|
||||
return Release{}, conflict("INVALID_FIRMWARE", "请选择型号,填写版本名称并上传不超过32MB的固件")
|
||||
}
|
||||
if s.Store == nil {
|
||||
return Release{}, conflict("STORAGE_UNAVAILABLE", "固件存储未配置")
|
||||
}
|
||||
var exists bool
|
||||
if err = s.Pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device WHERE tenant_id=$1 AND model=$2)`, tenant, in.Model).Scan(&exists); err != nil {
|
||||
return Release{}, err
|
||||
}
|
||||
if !exists {
|
||||
return Release{}, conflict("MODEL_NOT_FOUND", "请先在设备库存登记该型号")
|
||||
}
|
||||
// Staging objects are never distributed. Finalize copies a verified immutable object.
|
||||
object := "fleet/" + tenant + "/" + uuid.NewString() + ".bin"
|
||||
url, err := s.Store.PresignUpload(ctx, object, 15*time.Minute)
|
||||
if err != nil {
|
||||
return Release{}, err
|
||||
}
|
||||
r, err := scanRelease(s.Pool.QueryRow(ctx, `INSERT INTO fleet_firmware(tenant_id,model,version,release_notes,size,sha256,object_ref) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(tenant_id,model,version) DO UPDATE SET release_notes=excluded.release_notes,size=excluded.size,sha256=excluded.sha256,object_ref=excluded.object_ref WHERE fleet_firmware.status='UPLOADING' RETURNING `+releaseFields, tenant, in.Model, in.Version, in.Notes, in.Size, in.SHA256, object))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Release{}, conflict("VERSION_EXISTS", "该型号已有同名已发布版本,请使用新的版本名称")
|
||||
}
|
||||
r.UploadURL = url
|
||||
return r, err
|
||||
}
|
||||
func (s *Service) Finalize(ctx context.Context, tenant, id string) (Release, error) {
|
||||
r, err := scanRelease(s.Pool.QueryRow(ctx, `SELECT `+releaseFields+` FROM fleet_firmware WHERE tenant_id=$1 AND id=$2`, tenant, id))
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
if r.Status == "READY" {
|
||||
return r, nil
|
||||
}
|
||||
if s.Store == nil {
|
||||
return r, conflict("STORAGE_UNAVAILABLE", "固件存储未配置")
|
||||
}
|
||||
// SHA-256 is included in every OTA command. Devices must verify it before booting.
|
||||
finalObject := "fleet/ready/" + uuid.NewString() + ".bin"
|
||||
if err = s.Store.FreezeFirmware(ctx, r.Object, finalObject, r.SHA256, r.Size); err != nil {
|
||||
return r, conflict("FIRMWARE_INVALID", "文件大小或SHA256不匹配,请重新上传")
|
||||
}
|
||||
tag, err := s.Pool.Exec(ctx, `UPDATE fleet_firmware SET status='READY',object_ref=$3 WHERE id=$1 AND tenant_id=$2 AND status='UPLOADING' AND object_ref=$4`, id, tenant, finalObject, r.Object)
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return Release{}, conflict("UPLOAD_CHANGED", "上传状态已变化,请刷新后重试")
|
||||
}
|
||||
return scanRelease(s.Pool.QueryRow(ctx, `SELECT `+releaseFields+` FROM fleet_firmware WHERE id=$1 AND tenant_id=$2`, id, tenant))
|
||||
}
|
||||
func (s *Service) Download(ctx context.Context, tenant, id string) (Release, error) {
|
||||
r, err := scanRelease(s.Pool.QueryRow(ctx, `SELECT `+releaseFields+` FROM fleet_firmware WHERE tenant_id=$1 AND id=$2 AND status='READY'`, tenant, id))
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
err = s.download(ctx, &r)
|
||||
return r, err
|
||||
}
|
||||
func (s *Service) download(ctx context.Context, r *Release) error {
|
||||
if s.Store == nil {
|
||||
return conflict("STORAGE_UNAVAILABLE", "固件存储未配置")
|
||||
}
|
||||
_, tokenHash, address, expiresAt, err := newDownloadTicket(s.DownloadBaseURL)
|
||||
if err != nil {
|
||||
return conflict("DOWNLOAD_UNAVAILABLE", "固件短下载地址未正确配置")
|
||||
}
|
||||
if _, err = s.Pool.Exec(ctx, `INSERT INTO fleet_firmware_download_ticket(token_hash,release_id,expires_at) VALUES($1,$2,$3)`, tokenHash, r.ID, expiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
r.DownloadURL = address
|
||||
return nil
|
||||
}
|
||||
func Password(key []byte, factory string) string {
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write([]byte("MQTT1\n" + factory))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
func StatusTopic(factory string) string { return "vl/devices/" + factory + "/status" }
|
||||
func OTATopic(factory string) string { return "vl/devices/" + factory + "/ota" }
|
||||
func (s *Service) Config(ctx context.Context, device, factory string, key []byte) (map[string]any, error) {
|
||||
if s.BrokerURL == "" || s.Publish == nil {
|
||||
return nil, conflict("MQTT_UNAVAILABLE", "MQTT服务未启用")
|
||||
}
|
||||
password := Password(key, factory)
|
||||
hash := sha256.Sum256([]byte(password))
|
||||
_, err := s.Pool.Exec(ctx, `INSERT INTO fleet_presence(device_id,password_hash) VALUES($1,$2) ON CONFLICT(device_id) DO UPDATE SET password_hash=excluded.password_hash`, device, hash[:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"broker_url": s.BrokerURL, "client_id": factory, "username": factory, "password": password, "heartbeat_interval_seconds": 30, "keepalive_seconds": 60, "status_topic": StatusTopic(factory), "ota_topic": OTATopic(factory), "qos": 1}, nil
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
ID string `json:"task_id"`
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
Code int64 `json:"version_code"`
|
||||
Message string `json:"message"`
|
||||
Created time.Time `json:"created_at"`
|
||||
}
|
||||
type State struct {
|
||||
Online bool `json:"online"`
|
||||
LastSeen *time.Time `json:"last_seen_at"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
CurrentCode int64 `json:"current_version_code"`
|
||||
CurrentURL string `json:"current_download_url"`
|
||||
OTASupported bool `json:"ota_supported"`
|
||||
Latest *Release `json:"latest"`
|
||||
UpdateAvailable bool `json:"update_available"`
|
||||
Task *Task `json:"ota_task"`
|
||||
}
|
||||
|
||||
func (s *Service) State(ctx context.Context, device string) (State, error) {
|
||||
var out State
|
||||
var tenant, model string
|
||||
err := s.Pool.QueryRow(ctx, `SELECT d.tenant_id::text,d.model,COALESCE(p.connected AND p.last_seen_at>now()-interval '90 seconds',false),p.last_seen_at,COALESCE(NULLIF(p.firmware_version,''),dt.firmware_version,''),COALESCE(p.version_code,0),COALESCE(p.ota_supported,false) FROM device d LEFT JOIN fleet_presence p ON p.device_id=d.id LEFT JOIN device_twin dt ON dt.device_id=d.id WHERE d.id=$1`, device).Scan(&tenant, &model, &out.Online, &out.LastSeen, &out.CurrentVersion, &out.CurrentCode, &out.OTASupported)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
latest, err := scanRelease(s.Pool.QueryRow(ctx, `SELECT `+releaseFields+` FROM fleet_firmware WHERE tenant_id=$1 AND model=$2 AND status='READY' ORDER BY version_code DESC LIMIT 1`, tenant, model))
|
||||
if err == nil {
|
||||
if err = s.download(ctx, &latest); err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Latest = &latest
|
||||
out.UpdateAvailable = latest.Code > out.CurrentCode
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return out, err
|
||||
}
|
||||
current, err := scanRelease(s.Pool.QueryRow(ctx, `SELECT `+releaseFields+` FROM fleet_firmware WHERE tenant_id=$1 AND model=$2 AND version_code=$3 AND status='READY'`, tenant, model, out.CurrentCode))
|
||||
if err == nil {
|
||||
if err = s.download(ctx, ¤t); err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.CurrentURL = current.DownloadURL
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return out, err
|
||||
}
|
||||
var t Task
|
||||
err = s.Pool.QueryRow(ctx, `SELECT o.id::text,o.status,f.version,f.version_code,o.message,o.created_at FROM fleet_ota o JOIN fleet_firmware f ON f.id=o.release_id WHERE o.device_id=$1 ORDER BY o.created_at DESC LIMIT 1`, device).Scan(&t.ID, &t.Status, &t.Version, &t.Code, &t.Message, &t.Created)
|
||||
if err == nil {
|
||||
out.Task = &t
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return out, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type Report struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Status string `json:"status"`
|
||||
FirmwareVersion string `json:"firmware_version"`
|
||||
Code int64 `json:"version_code"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) Report(ctx context.Context, device string, in Report) error {
|
||||
if _, err := uuid.Parse(in.TaskID); err != nil {
|
||||
return conflict("INVALID_REPORT", "升级任务ID不正确")
|
||||
}
|
||||
if in.Status != "accepted" && in.Status != "success" && in.Status != "failed" || len(in.Message) > 500 {
|
||||
return conflict("INVALID_REPORT", "升级状态不正确")
|
||||
}
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var status, version string
|
||||
var code int64
|
||||
var created time.Time
|
||||
err = tx.QueryRow(ctx, `SELECT o.status,f.version,f.version_code,o.created_at FROM fleet_ota o JOIN fleet_firmware f ON f.id=o.release_id WHERE o.id=$1 AND o.device_id=$2 FOR UPDATE OF o`, in.TaskID, device).Scan(&status, &version, &code, &created)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return conflict("OTA_NOT_FOUND", "本设备没有该升级任务")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Status == "success" && (in.Code != code || in.FirmwareVersion != version) {
|
||||
return conflict("VERSION_MISMATCH", "升级完成上报必须匹配目标版本和顺序号")
|
||||
}
|
||||
if status == in.Status {
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
if in.Status == "accepted" && time.Since(created) > 30*time.Second {
|
||||
return conflict("OTA_EXPIRED", "升级指令已过期,请刷新后重新发起")
|
||||
}
|
||||
if status == "success" || status == "failed" {
|
||||
return conflict("OTA_FINISHED", "该升级任务已经结束")
|
||||
}
|
||||
if status == "timeout" && in.Status == "accepted" {
|
||||
return conflict("OTA_EXPIRED", "升级指令已过期")
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE fleet_ota SET status=$2,message=$3,updated_at=now() WHERE id=$1`, in.TaskID, in.Status, in.Message)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Status == "success" || in.Status == "failed" {
|
||||
if _, err = tx.Exec(ctx, `UPDATE fleet_firmware_download_ticket SET revoked_at=now() WHERE ota_id=$1 AND revoked_at IS NULL`, in.TaskID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if in.Status == "success" {
|
||||
var tag pgconn.CommandTag
|
||||
tag, err = tx.Exec(ctx, `UPDATE fleet_presence SET firmware_version=$2,version_code=$3 WHERE device_id=$1 AND version_code<=$3`, device, version, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE device_twin SET firmware_version=$2,updated_at=now() WHERE device_id=$1`, device, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package devicefleet
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/mochi-mqtt/server/v2/packets"
|
||||
)
|
||||
|
||||
type fakeStore struct{ objects map[string][]byte }
|
||||
|
||||
type fakeDownload struct{ *bytes.Reader }
|
||||
|
||||
func (fakeDownload) Close() error { return nil }
|
||||
|
||||
func (f *fakeStore) PresignUpload(_ context.Context, key string, _ time.Duration) (string, error) {
|
||||
return "https://example.test/" + key, nil
|
||||
}
|
||||
func (f *fakeStore) PresignMediaDownload(_ context.Context, key string, _ time.Duration, _, _ string, _ bool) (string, error) {
|
||||
return "https://example.test/" + key, nil
|
||||
}
|
||||
func (f *fakeStore) OpenDownload(_ context.Context, key string) (io.ReadSeekCloser, error) {
|
||||
content, ok := f.objects[key]
|
||||
if !ok {
|
||||
return nil, errors.New("missing object")
|
||||
}
|
||||
return fakeDownload{bytes.NewReader(content)}, nil
|
||||
}
|
||||
func (f *fakeStore) VerifyDigest(_ context.Context, key, hash string, size int64) error {
|
||||
b := f.objects[key]
|
||||
sum := sha256.Sum256(b)
|
||||
if int64(len(b)) != size || hex.EncodeToString(sum[:]) != hash {
|
||||
return errors.New("bad object")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStore) FreezeFirmware(ctx context.Context, source, target, hash string, size int64) error {
|
||||
f.objects[target] = bytes.Clone(f.objects[source])
|
||||
return f.VerifyDigest(ctx, target, hash, size)
|
||||
}
|
||||
func TestHeartbeatValidation(t *testing.T) {
|
||||
for _, b := range []Heartbeat{{true, "", 0, true}, {true, "x", -1, true}, {true, strings.Repeat("x", 81), 1, true}} {
|
||||
if ValidateHeartbeat(b) {
|
||||
t.Fatal(b)
|
||||
}
|
||||
}
|
||||
if !ValidateHeartbeat(Heartbeat{true, "1.0.0", 0, true}) || !ValidateHeartbeat(Heartbeat{}) {
|
||||
t.Fatal("valid heartbeat rejected")
|
||||
}
|
||||
}
|
||||
func TestPasswordDeterministicDeviceScoped(t *testing.T) {
|
||||
key := bytes.Repeat([]byte{7}, 32)
|
||||
a := Password(key, "device-a")
|
||||
if len(a) != 43 || a != Password(key, "device-a") || a == Password(key, "device-b") {
|
||||
t.Fatal("password derivation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokerRejectsTLSURL(t *testing.T) {
|
||||
s := &Service{BrokerURL: "mqtts://127.0.0.1:8883"}
|
||||
if _, err := s.StartBroker(context.Background(), "127.0.0.1:0", slog.New(slog.NewTextHandler(io.Discard, nil))); err == nil {
|
||||
t.Fatal("TLS broker URL accepted for a plaintext listener")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFleetMQTTAndOTALifecycle(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("isolated integration database required")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
must(t, err)
|
||||
defer pool.Close()
|
||||
const device = "10000000-0000-0000-0000-000000000502"
|
||||
const other = "10000000-0000-0000-0000-000000000501"
|
||||
const tenant = "10000000-0000-0000-0000-000000000001"
|
||||
const factory = "KP01-020000001234"
|
||||
_, err = pool.Exec(ctx, `UPDATE device SET factory_device_id=$2 WHERE id=$1`, device, factory)
|
||||
must(t, err)
|
||||
store := &fakeStore{objects: map[string][]byte{}}
|
||||
s := &Service{Pool: pool, Store: store, BrokerURL: "mqtt://127.0.0.1:1883", DownloadBaseURL: "https://82.157.201.114"}
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
must(t, err)
|
||||
address := listener.Addr().String()
|
||||
listener.Close()
|
||||
stop, err := s.StartBroker(ctx, address, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
must(t, err)
|
||||
defer stop()
|
||||
config, err := s.Config(ctx, device, factory, bytes.Repeat([]byte{7}, 32))
|
||||
must(t, err)
|
||||
connect := func(password string) net.Conn {
|
||||
t.Helper()
|
||||
c, e := net.Dial("tcp", address)
|
||||
must(t, e)
|
||||
pk := packets.Packet{FixedHeader: packets.FixedHeader{Type: packets.Connect}, ProtocolVersion: 4, Connect: packets.ConnectParams{ProtocolName: []byte("MQTT"), ClientIdentifier: factory, Username: []byte(factory), Password: []byte(password), UsernameFlag: true, PasswordFlag: true, Clean: true, Keepalive: 60}}
|
||||
var b bytes.Buffer
|
||||
must(t, pk.ConnectEncode(&b))
|
||||
_, e = c.Write(b.Bytes())
|
||||
must(t, e)
|
||||
return c
|
||||
}
|
||||
bad := connect("wrong")
|
||||
_, ack := readPacket(t, bad)
|
||||
if ack[len(ack)-1] == 0 {
|
||||
t.Fatal("bad credentials accepted")
|
||||
}
|
||||
bad.Close()
|
||||
c := connect(config["password"].(string))
|
||||
defer c.Close()
|
||||
kind, ack := readPacket(t, c)
|
||||
if kind>>4 != packets.Connack || ack[len(ack)-1] != 0 {
|
||||
t.Fatal("connect", kind, ack)
|
||||
}
|
||||
state, err := s.State(ctx, device)
|
||||
must(t, err)
|
||||
if state.Online {
|
||||
t.Fatal("connection without heartbeat is not online")
|
||||
}
|
||||
subscribe := func(topic string, id uint16) byte {
|
||||
pk := packets.Packet{FixedHeader: packets.FixedHeader{Type: packets.Subscribe, Qos: 1}, ProtocolVersion: 4, PacketID: id, Filters: packets.Subscriptions{{Filter: topic, Qos: 1}}}
|
||||
var b bytes.Buffer
|
||||
must(t, pk.SubscribeEncode(&b))
|
||||
_, e := c.Write(b.Bytes())
|
||||
must(t, e)
|
||||
_, data := readPacket(t, c)
|
||||
return data[len(data)-1]
|
||||
}
|
||||
if subscribe("vl/devices/another/ota", 1) != 0x80 {
|
||||
t.Fatal("cross-device subscription allowed")
|
||||
}
|
||||
if subscribe(OTATopic(factory), 2) != 1 {
|
||||
t.Fatal("own OTA subscription failed")
|
||||
}
|
||||
heartbeat := func(version string, code int64) {
|
||||
payload, _ := json.Marshal(Heartbeat{true, version, code, true})
|
||||
pk := packets.Packet{FixedHeader: packets.FixedHeader{Type: packets.Publish, Qos: 1}, ProtocolVersion: 4, PacketID: 3, TopicName: StatusTopic(factory), Payload: payload}
|
||||
var b bytes.Buffer
|
||||
must(t, pk.PublishEncode(&b))
|
||||
_, e := c.Write(b.Bytes())
|
||||
must(t, e)
|
||||
k, _ := readPacket(t, c)
|
||||
if k>>4 != packets.Puback {
|
||||
t.Fatal("heartbeat not acknowledged")
|
||||
}
|
||||
}
|
||||
heartbeat("factory", 0)
|
||||
state, err = s.State(ctx, device)
|
||||
must(t, err)
|
||||
if !state.Online || !state.OTASupported {
|
||||
t.Fatal("heartbeat not visible", state)
|
||||
}
|
||||
authorize := func(tx pgx.Tx) (string, error) {
|
||||
var id string
|
||||
e := tx.QueryRow(ctx, `SELECT device_id::text FROM device_twin WHERE device_id=$1 FOR UPDATE`, device).Scan(&id)
|
||||
return id, e
|
||||
}
|
||||
sum := sha256.Sum256([]byte("firmware-v1"))
|
||||
input := ReleaseInput{Model: "simulated-terminal", Version: "1.0.0", Notes: "test", Size: 11, SHA256: hex.EncodeToString(sum[:])}
|
||||
r, err := s.Prepare(ctx, tenant, input)
|
||||
must(t, err)
|
||||
if r.Code <= 0 {
|
||||
t.Fatal("missing generated sequence")
|
||||
}
|
||||
if _, e := s.Finalize(ctx, tenant, r.ID); e == nil {
|
||||
t.Fatal("missing file published")
|
||||
}
|
||||
store.objects[r.Object] = []byte("firmware-v1")
|
||||
input.Size = int64(len(store.objects[r.Object])) // correct the expected size by retrying the staged upload
|
||||
r, err = s.Prepare(ctx, tenant, input)
|
||||
must(t, err)
|
||||
store.objects[r.Object] = []byte("firmware-v1")
|
||||
r, err = s.Finalize(ctx, tenant, r.ID)
|
||||
must(t, err)
|
||||
download, err := s.Download(ctx, tenant, r.ID)
|
||||
must(t, err)
|
||||
if len(download.DownloadURL) > 64 || !strings.HasPrefix(download.DownloadURL, "https://82.157.201.114/f/") {
|
||||
t.Fatal("firmware API did not return a compact download URL", download.DownloadURL)
|
||||
}
|
||||
adminToken := strings.TrimPrefix(download.DownloadURL, "https://82.157.201.114/f/")
|
||||
adminGrant, err := s.ResolveDownload(ctx, adminToken)
|
||||
must(t, err)
|
||||
if adminGrant.SHA256 != r.SHA256 || adminGrant.Size != r.Size {
|
||||
t.Fatal("firmware API ticket resolved to the wrong firmware", adminGrant)
|
||||
}
|
||||
if _, e := s.Prepare(ctx, tenant, input); e == nil {
|
||||
t.Fatal("published version overwritten")
|
||||
}
|
||||
state, err = s.State(ctx, device)
|
||||
must(t, err)
|
||||
if !state.UpdateAvailable || state.Latest.Code != r.Code {
|
||||
t.Fatal("latest version missing")
|
||||
}
|
||||
// The older rollout path can have an ASSIGNED device after its command has
|
||||
// expired. It must still exclude a second OTA through the MQTT fleet path.
|
||||
keyID, legacyReleaseID, legacyRolloutID := uuid.NewString(), uuid.NewString(), uuid.NewString()
|
||||
const creator = "10000000-0000-0000-0000-000000000101"
|
||||
const household = "10000000-0000-0000-0000-000000000201"
|
||||
_, err = pool.Exec(ctx, `INSERT INTO firmware_signing_key(tenant_id,key_id,public_key_base64,created_by) VALUES($1,$2,'test',$3)`, tenant, keyID, creator)
|
||||
must(t, err)
|
||||
_, err = pool.Exec(ctx, `INSERT INTO firmware_release(id,tenant_id,household_id,model,version,object_ref,expected_size,expected_digest,signature,signing_key_id,created_by) VALUES($1,$2,$3,'simulated-terminal',$4,$4,11,$5,'test',$6,$7)`, legacyReleaseID, tenant, household, legacyReleaseID, r.SHA256, keyID, creator)
|
||||
must(t, err)
|
||||
_, err = pool.Exec(ctx, `INSERT INTO firmware_rollout(id,tenant_id,household_id,release_id,name,status,target_percentage,created_by) VALUES($1,$2,$3,$4,'mutual exclusion test','RUNNING',100,$5)`, legacyRolloutID, tenant, household, legacyReleaseID, creator)
|
||||
must(t, err)
|
||||
_, err = pool.Exec(ctx, `INSERT INTO firmware_assignment(rollout_id,tenant_id,device_id,status) VALUES($1,$2,$3,'ASSIGNED')`, legacyRolloutID, tenant, device)
|
||||
must(t, err)
|
||||
_, err = s.Start(ctx, uuid.NewString(), r.Code, authorize)
|
||||
var busyError *Error
|
||||
if !errors.As(err, &busyError) || busyError.Code != "DEVICE_BUSY" {
|
||||
t.Fatalf("ASSIGNED rollout allowed a parallel fleet OTA: %v", err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `DELETE FROM firmware_rollout WHERE id=$1`, legacyRolloutID)
|
||||
must(t, err)
|
||||
_, err = pool.Exec(ctx, `DELETE FROM firmware_release WHERE id=$1`, legacyReleaseID)
|
||||
must(t, err)
|
||||
_, err = pool.Exec(ctx, `DELETE FROM firmware_signing_key WHERE tenant_id=$1 AND key_id=$2`, tenant, keyID)
|
||||
must(t, err)
|
||||
request := uuid.NewString()
|
||||
task, err := s.Start(ctx, request, r.Code, authorize)
|
||||
must(t, err)
|
||||
typ, payload := readPacket(t, c)
|
||||
if typ>>4 != packets.Publish || typ&1 != 0 {
|
||||
t.Fatal("expected non-retained OTA publish")
|
||||
}
|
||||
topicLength := int(payload[0])<<8 | int(payload[1])
|
||||
var command map[string]any
|
||||
must(t, json.Unmarshal(payload[2+topicLength+2:], &command))
|
||||
if command["task_id"] != task.ID || command["sha256"] != r.SHA256 {
|
||||
t.Fatal("bad OTA command")
|
||||
}
|
||||
downloadURL, ok := command["download_url"].(string)
|
||||
if !ok || len(downloadURL) > 64 || !strings.HasPrefix(downloadURL, "https://82.157.201.114/f/") {
|
||||
t.Fatal("OTA command did not contain a compact download URL", downloadURL)
|
||||
}
|
||||
token := strings.TrimPrefix(downloadURL, "https://82.157.201.114/f/")
|
||||
grant, err := s.ResolveDownload(ctx, token)
|
||||
must(t, err)
|
||||
if grant.SHA256 != r.SHA256 || grant.Size != r.Size {
|
||||
t.Fatal("short download ticket resolved to the wrong firmware", grant)
|
||||
}
|
||||
reader, err := s.OpenDownload(ctx, grant)
|
||||
must(t, err)
|
||||
downloaded, err := io.ReadAll(reader)
|
||||
must(t, err)
|
||||
must(t, reader.Close())
|
||||
if !bytes.Equal(downloaded, []byte("firmware-v1")) {
|
||||
t.Fatal("short download returned wrong content")
|
||||
}
|
||||
again, err := s.Start(ctx, request, r.Code, authorize)
|
||||
must(t, err)
|
||||
if again.ID != task.ID {
|
||||
t.Fatal("retry duplicated task")
|
||||
}
|
||||
if _, e := s.Start(ctx, uuid.NewString(), r.Code, authorize); e == nil {
|
||||
t.Fatal("parallel OTA allowed")
|
||||
}
|
||||
if e := s.Report(ctx, other, Report{TaskID: task.ID, Status: "success", FirmwareVersion: r.Version, Code: r.Code}); e == nil {
|
||||
t.Fatal("another device completed task")
|
||||
}
|
||||
if e := s.Report(ctx, device, Report{TaskID: task.ID, Status: "success", FirmwareVersion: "wrong", Code: r.Code}); e == nil {
|
||||
t.Fatal("wrong firmware accepted")
|
||||
}
|
||||
manifest, err := s.Manifest(ctx, device, task.ID)
|
||||
must(t, err)
|
||||
if manifest["sha256"] != r.SHA256 || manifest["version_code"] != r.Code || manifest["model"] != r.Model {
|
||||
t.Fatal("manifest differs from immutable release", manifest)
|
||||
}
|
||||
manifestToken := strings.TrimPrefix(manifest["download_url"].(string), "https://82.157.201.114/f/")
|
||||
manifestGrant, err := s.ResolveDownload(ctx, manifestToken)
|
||||
must(t, err)
|
||||
if manifestGrant.SHA256 != r.SHA256 {
|
||||
t.Fatal("HTTPS manifest ticket returned different bytes")
|
||||
}
|
||||
if _, err = s.Manifest(ctx, other, task.ID); err == nil {
|
||||
t.Fatal("foreign device obtained OTA manifest")
|
||||
}
|
||||
must(t, s.Report(ctx, device, Report{TaskID: task.ID, Status: "accepted"}))
|
||||
must(t, s.Report(ctx, device, Report{TaskID: task.ID, Status: "success", FirmwareVersion: r.Version, Code: r.Code}))
|
||||
if _, err = s.Manifest(ctx, device, task.ID); err == nil {
|
||||
t.Fatal("completed task gained a new download grant")
|
||||
}
|
||||
must(t, s.Report(ctx, device, Report{TaskID: task.ID, Status: "success", FirmwareVersion: r.Version, Code: r.Code}))
|
||||
if _, err = s.ResolveDownload(ctx, token); !errors.Is(err, ErrDownloadTicketUnavailable) {
|
||||
t.Fatal("completed OTA download ticket remained usable", err)
|
||||
}
|
||||
state, err = s.State(ctx, device)
|
||||
must(t, err)
|
||||
if state.UpdateAvailable || state.CurrentCode != r.Code || state.Task.Status != "success" {
|
||||
t.Fatal("success not reflected", state)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `UPDATE fleet_presence SET last_seen_at=now()-interval '91 seconds' WHERE device_id=$1`, device)
|
||||
must(t, err)
|
||||
state, err = s.State(ctx, device)
|
||||
must(t, err)
|
||||
if state.Online {
|
||||
t.Fatal("stale heartbeat is online")
|
||||
}
|
||||
if _, e := s.Start(ctx, uuid.NewString(), r.Code, authorize); e == nil {
|
||||
t.Fatal("offline upgrade allowed")
|
||||
}
|
||||
_, err = pool.Exec(ctx, `INSERT INTO fleet_ota(device_id,release_id,request_id,created_at) VALUES($1,$2,$3,now()-interval '31 seconds')`, device, r.ID, uuid.NewString())
|
||||
must(t, err)
|
||||
must(t, s.Expire(ctx))
|
||||
state, err = s.State(ctx, device)
|
||||
must(t, err)
|
||||
var pending int
|
||||
must(t, pool.QueryRow(ctx, `SELECT count(*) FROM fleet_ota WHERE device_id=$1 AND status='pending'`, device).Scan(&pending))
|
||||
if pending != 0 {
|
||||
t.Fatal("pending task not expired")
|
||||
}
|
||||
}
|
||||
func must(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func readPacket(t *testing.T, c net.Conn) (byte, []byte) {
|
||||
t.Helper()
|
||||
must(t, c.SetReadDeadline(time.Now().Add(5*time.Second)))
|
||||
var first [1]byte
|
||||
_, e := io.ReadFull(c, first[:])
|
||||
must(t, e)
|
||||
size, mul := 0, 1
|
||||
for i := 0; i < 4; i++ {
|
||||
var b [1]byte
|
||||
_, e = io.ReadFull(c, b[:])
|
||||
must(t, e)
|
||||
size += int(b[0]&127) * mul
|
||||
if b[0]&128 == 0 {
|
||||
break
|
||||
}
|
||||
mul *= 128
|
||||
}
|
||||
body := make([]byte, size)
|
||||
_, e = io.ReadFull(c, body)
|
||||
must(t, e)
|
||||
return first[0], body
|
||||
}
|
||||
Reference in New Issue
Block a user