Deploy MQTT test backend with private viewer and Gitea CI/CD
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
// Package contracts defines the isolated test payloads, not the existing device protocol.
|
||||
package contracts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const MaxPayloadBytes = 16384
|
||||
const MaxCommandLifetime = time.Minute
|
||||
|
||||
var safeID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`)
|
||||
|
||||
type Envelope struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
DeviceID string `json:"device_id"`
|
||||
}
|
||||
type Telemetry struct {
|
||||
Envelope
|
||||
MessageID string `json:"message_id"`
|
||||
BootID string `json:"boot_id"`
|
||||
Sequence *uint64 `json:"sequence"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Readings map[string]float64 `json:"readings"`
|
||||
}
|
||||
type Status struct {
|
||||
Envelope
|
||||
BootID string `json:"boot_id"`
|
||||
State string `json:"state"`
|
||||
Timestamp string `json:"timestamp,omitempty"`
|
||||
WillConfiguredAt string `json:"will_configured_at,omitempty"`
|
||||
Implementation string `json:"implementation,omitempty"`
|
||||
ReportedProtocol string `json:"reported_protocol,omitempty"`
|
||||
}
|
||||
type Command struct {
|
||||
Envelope
|
||||
RequestID string `json:"request_id"`
|
||||
IssuedAt string `json:"issued_at"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
type Result struct {
|
||||
Pong *bool `json:"pong,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
type AckError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
type Ack struct {
|
||||
Envelope
|
||||
RequestID string `json:"request_id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Stage string `json:"stage"`
|
||||
OK *bool `json:"ok"`
|
||||
Result *Result `json:"result,omitempty"`
|
||||
Error *AckError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (e Envelope) validate(device string) error {
|
||||
if e.SchemaVersion != 1 {
|
||||
return fmt.Errorf("schema_version: expected 1")
|
||||
}
|
||||
if !safeID.MatchString(e.DeviceID) || e.DeviceID != device {
|
||||
return fmt.Errorf("device_id: invalid or mismatched topic identity")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func id(name, value string) error {
|
||||
if !safeID.MatchString(value) {
|
||||
return fmt.Errorf("%s: expected 1..64 safe identifier characters", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func timestamp(name, value string) (time.Time, error) {
|
||||
t, err := time.Parse(time.RFC3339Nano, value)
|
||||
if err != nil || !strings.HasSuffix(value, "Z") {
|
||||
return time.Time{}, fmt.Errorf("%s: expected RFC3339 UTC timestamp ending Z", name)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
func (m Telemetry) Validate(device string) error {
|
||||
if err := m.Envelope.validate(device); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := id("message_id", m.MessageID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := id("boot_id", m.BootID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := timestamp("timestamp", m.Timestamp); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Sequence == nil {
|
||||
return fmt.Errorf("sequence: required")
|
||||
}
|
||||
if len(m.Readings) == 0 || len(m.Readings) > 16 {
|
||||
return fmt.Errorf("readings: expected 1..16 numeric readings")
|
||||
}
|
||||
for key, value := range m.Readings {
|
||||
if !safeID.MatchString(key) || math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return fmt.Errorf("readings: invalid name or nonfinite value")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m Status) Validate(device string) error {
|
||||
if err := m.Envelope.validate(device); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := id("boot_id", m.BootID); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.State != "online" && m.State != "offline" {
|
||||
return fmt.Errorf("state: expected online or offline")
|
||||
}
|
||||
if m.Timestamp != "" && m.WillConfiguredAt != "" {
|
||||
return fmt.Errorf("timestamp: cannot be combined with will_configured_at")
|
||||
}
|
||||
if m.WillConfiguredAt != "" {
|
||||
if m.State != "offline" {
|
||||
return fmt.Errorf("will_configured_at: only valid for offline Will")
|
||||
}
|
||||
if _, err := timestamp("will_configured_at", m.WillConfiguredAt); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err := timestamp("timestamp", m.Timestamp); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if m.Implementation != "" {
|
||||
if err := id("implementation", m.Implementation); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if m.ReportedProtocol != "" && m.ReportedProtocol != "5" && m.ReportedProtocol != "3.1.1" {
|
||||
return fmt.Errorf("reported_protocol: expected 5 or 3.1.1")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m Command) Validate(device string) error {
|
||||
if err := m.Envelope.validate(device); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := id("request_id", m.RequestID); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Command != "ping" && m.Command != "get_version" {
|
||||
return fmt.Errorf("command: only ping or get_version allowed")
|
||||
}
|
||||
issued, err := timestamp("issued_at", m.IssuedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expires, err := timestamp("expires_at", m.ExpiresAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lifetime := expires.Sub(issued)
|
||||
if lifetime < time.Second || lifetime > MaxCommandLifetime {
|
||||
return fmt.Errorf("expires_at: lifetime must be 1s..1m after issued_at")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckDeadline is deliberately separate from structural validation, for delayed/replayed messages.
|
||||
func (m Command) CheckDeadline(now time.Time) error {
|
||||
if err := m.Validate(m.DeviceID); err != nil {
|
||||
return err
|
||||
}
|
||||
issued, _ := timestamp("issued_at", m.IssuedAt)
|
||||
expires, _ := timestamp("expires_at", m.ExpiresAt)
|
||||
if now.Before(issued) {
|
||||
return fmt.Errorf("issued_at: command is from the future; check device clock")
|
||||
}
|
||||
if !now.Before(expires) {
|
||||
return fmt.Errorf("expires_at: command expired")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m Ack) Validate(device string) error {
|
||||
if err := m.Envelope.validate(device); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := id("request_id", m.RequestID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := timestamp("timestamp", m.Timestamp); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.OK == nil {
|
||||
return fmt.Errorf("ok: boolean is required")
|
||||
}
|
||||
switch m.Stage {
|
||||
case "accepted":
|
||||
if !*m.OK || m.Result != nil || m.Error != nil {
|
||||
return fmt.Errorf("accepted: requires ok=true without result/error")
|
||||
}
|
||||
case "completed":
|
||||
if !*m.OK || m.Error != nil || m.Result == nil {
|
||||
return fmt.Errorf("completed: requires ok=true and result without error")
|
||||
}
|
||||
if (m.Result.Pong != nil) == (m.Result.Version != "") {
|
||||
return fmt.Errorf("result: expected exactly one of pong or version")
|
||||
}
|
||||
if m.Result.Pong != nil && !*m.Result.Pong {
|
||||
return fmt.Errorf("result.pong: expected true")
|
||||
}
|
||||
if m.Result.Version != "" && !regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$`).MatchString(m.Result.Version) {
|
||||
return fmt.Errorf("result.version: invalid or too long")
|
||||
}
|
||||
case "rejected", "failed":
|
||||
if *m.OK || m.Error == nil || m.Result != nil {
|
||||
return fmt.Errorf("rejected/failed: requires ok=false and error without result")
|
||||
}
|
||||
if err := id("error.code", m.Error.Code); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Error.Message == "" || len(m.Error.Message) > 256 {
|
||||
return fmt.Errorf("error.message: expected 1..256 bytes")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("stage: expected accepted, completed, rejected or failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decode rejects duplicate keys, unknown fields, invalid UTF-8 and trailing values.
|
||||
// Errors contain no payload contents. The returned concrete type implements Validate.
|
||||
func Decode(kind string, data []byte, device string) (any, error) {
|
||||
if len(data) > MaxPayloadBytes || len(data) == 0 {
|
||||
return nil, fmt.Errorf("payload: expected 1..16384 bytes")
|
||||
}
|
||||
if !utf8.Valid(data) {
|
||||
return nil, fmt.Errorf("payload: invalid UTF-8")
|
||||
}
|
||||
if err := uniqueObject(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var target interface{ Validate(string) error }
|
||||
switch kind {
|
||||
case "telemetry":
|
||||
target = &Telemetry{}
|
||||
case "status":
|
||||
target = &Status{}
|
||||
case "commands":
|
||||
target = &Command{}
|
||||
case "acks":
|
||||
target = &Ack{}
|
||||
default:
|
||||
return nil, fmt.Errorf("topic: unsupported message category")
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(target); err != nil {
|
||||
return nil, fmt.Errorf("payload: invalid JSON field or type")
|
||||
}
|
||||
if err := target.Validate(device); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
func uniqueObject(data []byte) error {
|
||||
d := json.NewDecoder(bytes.NewReader(data))
|
||||
d.UseNumber()
|
||||
first, err := d.Token()
|
||||
if err != nil || first != json.Delim('{') {
|
||||
return fmt.Errorf("payload: expected JSON object")
|
||||
}
|
||||
if err = walkObject(d, 0); err != nil {
|
||||
return fmt.Errorf("payload: malformed JSON, duplicate key or excessive nesting")
|
||||
}
|
||||
if _, err = d.Token(); err != io.EOF {
|
||||
return fmt.Errorf("payload: trailing JSON data")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func walkObject(d *json.Decoder, depth int) error {
|
||||
if depth > 8 {
|
||||
return fmt.Errorf("depth")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for d.More() {
|
||||
token, err := d.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, ok := token.(string)
|
||||
// encoding/json matches struct fields case-insensitively. Reject aliases
|
||||
// of an existing key as well as byte-identical duplicates.
|
||||
canonical := strings.ToLower(key)
|
||||
if !ok || seen[canonical] {
|
||||
return fmt.Errorf("duplicate")
|
||||
}
|
||||
seen[canonical] = true
|
||||
t, err := d.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if t == nil {
|
||||
return fmt.Errorf("null values unsupported")
|
||||
}
|
||||
if delim, ok := t.(json.Delim); ok {
|
||||
if delim != '{' {
|
||||
return fmt.Errorf("arrays unsupported")
|
||||
}
|
||||
if err = walkObject(d, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
end, err := d.Token()
|
||||
if err != nil || end != json.Delim('}') {
|
||||
return fmt.Errorf("object")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user