From 71ef622b4fc975f4ad78b216cfaaf79396d4c412 Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 8 Oct 2026 14:48:53 +0330 Subject: [PATCH] Deploy MQTT test backend with private viewer and Gitea CI/CD --- .dockerignore | 20 + .env.docker.example | 7 + .gitea/workflows/backend.yml | 35 ++ .gitignore | 25 + Dockerfile | 18 + README.md | 63 +++ cmd/device-sim/main.go | 16 + cmd/test-mqtt5/main.go | 20 + cmd/test-mqtt5/main_test.go | 53 ++ compose.yaml | 76 +++ config.example.toml | 65 +++ deploy/.env.example | 8 + deploy/.gitignore | 6 + deploy/acl | 14 + deploy/compose.insecure.yaml | 5 + deploy/compose.yaml | 67 +++ deploy/mosquitto.conf | 34 ++ deploy/scripts/healthcheck.sh | 20 + deploy/scripts/prepare.py | 135 +++++ deploy/scripts/verify.py | 168 +++++++ docs/BACKEND-SERVER.md | 45 ++ docs/CONFIGURATION.md | 73 +++ docs/CONTRACTS.md | 76 +++ docs/DEPLOYMENT.md | 162 ++++++ docs/DEVICE-MESSAGE-DIAGNOSTICS.md | 45 ++ docs/PLAN.md | 288 +++++++++++ docs/RUNNING.md | 193 ++++++++ docs/SPRINTS.md | 42 ++ docs/VERIFICATION.md | 60 +++ docs/sprints/01-foundation.md | 104 ++++ docs/sprints/02-local-broker.md | 96 ++++ docs/sprints/03-backend-simulator.md | 99 ++++ docs/sprints/04-browser-viewer.md | 81 +++ docs/sprints/05-reliability.md | 81 +++ docs/sprints/06-remote-broker.md | 68 +++ docs/sprints/07-device-compatibility.md | 71 +++ go.mod | 23 + go.sum | 40 ++ internal/backend/backend.go | 218 +++++++++ internal/backend/backend_test.go | 163 +++++++ internal/backend/receipts.go | 107 ++++ internal/backend/receipts_test.go | 108 ++++ internal/cli/cli.go | 215 ++++++++ internal/cli/cli_test.go | 34 ++ internal/config/config.go | 417 ++++++++++++++++ internal/config/config_test.go | 181 +++++++ internal/contracts/contracts.go | 326 +++++++++++++ internal/contracts/contracts_test.go | 94 ++++ internal/events/events.go | 138 ++++++ internal/events/events_test.go | 55 +++ internal/events/store.go | 144 ++++++ internal/events/store_test.go | 82 ++++ internal/integration/broker_test.go | 244 +++++++++ internal/integration/reconnect_test.go | 172 +++++++ internal/integration/reliability_test.go | 117 +++++ internal/mqtt/client.go | 461 ++++++++++++++++++ internal/mqtt/client_test.go | 92 ++++ internal/simulator/simulator.go | 193 ++++++++ internal/simulator/simulator_test.go | 103 ++++ internal/web/assets/app.js | 14 + internal/web/assets/style.css | 1 + internal/web/server.go | 320 ++++++++++++ internal/web/server_test.go | 164 +++++++ .../MQTT Test Viewer.postman_collection.json | 66 +++ postman/README.md | 21 + .../.resources/definition.yaml | 3 + .../Connect to Test Broker.request.yaml | 24 + scripts/check_receipts.py | 240 +++++++++ server/compose.yaml | 57 +++ server/deploy.sh | 47 ++ 70 files changed, 7123 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.docker.example create mode 100644 .gitea/workflows/backend.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 cmd/device-sim/main.go create mode 100644 cmd/test-mqtt5/main.go create mode 100644 cmd/test-mqtt5/main_test.go create mode 100644 compose.yaml create mode 100644 config.example.toml create mode 100644 deploy/.env.example create mode 100644 deploy/.gitignore create mode 100644 deploy/acl create mode 100644 deploy/compose.insecure.yaml create mode 100644 deploy/compose.yaml create mode 100644 deploy/mosquitto.conf create mode 100644 deploy/scripts/healthcheck.sh create mode 100644 deploy/scripts/prepare.py create mode 100644 deploy/scripts/verify.py create mode 100644 docs/BACKEND-SERVER.md create mode 100644 docs/CONFIGURATION.md create mode 100644 docs/CONTRACTS.md create mode 100644 docs/DEPLOYMENT.md create mode 100644 docs/DEVICE-MESSAGE-DIAGNOSTICS.md create mode 100644 docs/PLAN.md create mode 100644 docs/RUNNING.md create mode 100644 docs/SPRINTS.md create mode 100644 docs/VERIFICATION.md create mode 100644 docs/sprints/01-foundation.md create mode 100644 docs/sprints/02-local-broker.md create mode 100644 docs/sprints/03-backend-simulator.md create mode 100644 docs/sprints/04-browser-viewer.md create mode 100644 docs/sprints/05-reliability.md create mode 100644 docs/sprints/06-remote-broker.md create mode 100644 docs/sprints/07-device-compatibility.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/backend/backend.go create mode 100644 internal/backend/backend_test.go create mode 100644 internal/backend/receipts.go create mode 100644 internal/backend/receipts_test.go create mode 100644 internal/cli/cli.go create mode 100644 internal/cli/cli_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/contracts/contracts.go create mode 100644 internal/contracts/contracts_test.go create mode 100644 internal/events/events.go create mode 100644 internal/events/events_test.go create mode 100644 internal/events/store.go create mode 100644 internal/events/store_test.go create mode 100644 internal/integration/broker_test.go create mode 100644 internal/integration/reconnect_test.go create mode 100644 internal/integration/reliability_test.go create mode 100644 internal/mqtt/client.go create mode 100644 internal/mqtt/client_test.go create mode 100644 internal/simulator/simulator.go create mode 100644 internal/simulator/simulator_test.go create mode 100644 internal/web/assets/app.js create mode 100644 internal/web/assets/style.css create mode 100644 internal/web/server.go create mode 100644 internal/web/server_test.go create mode 100644 postman/MQTT Test Viewer.postman_collection.json create mode 100644 postman/README.md create mode 100644 postman/collections/MQTT Broker UI Test/.resources/definition.yaml create mode 100644 postman/collections/MQTT Broker UI Test/Connect to Test Broker.request.yaml create mode 100644 scripts/check_receipts.py create mode 100644 server/compose.yaml create mode 100644 server/deploy.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f1ec9c4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +.git +.idea +.cache +bin +deploy +docs +*.test +coverage.out +.env +.env.* +*.local.toml +viewer.local.toml +secrets +certs +data +*.key +*.pem +esp32-test-kit +esp32-test-kit.zip +docs/ESP32-DEVELOPER-HANDOFF.md diff --git a/.env.docker.example b/.env.docker.example new file mode 100644 index 0000000..0e7db83 --- /dev/null +++ b/.env.docker.example @@ -0,0 +1,7 @@ +# Host paths to the private test client files copied from the MQTT test server. +LOCAL_UID=1000 +LOCAL_GID=1000 +MQTT_BROKER_URL=tls://109.122.252.129:8884 +MQTT_CA_FILE=/home/ryan/mqtt-test-credentials/ca.crt +MQTT_BACKEND_CREDENTIALS_FILE=/home/ryan/mqtt-test-credentials/test-backend.json +MQTT_SIM_CREDENTIALS_FILE=/home/ryan/mqtt-test-credentials/sim-001.json diff --git a/.gitea/workflows/backend.yml b/.gitea/workflows/backend.yml new file mode 100644 index 0000000..c146185 --- /dev/null +++ b/.gitea/workflows/backend.yml @@ -0,0 +1,35 @@ +name: Test and deploy MQTT backend +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: +concurrency: + group: mqtt5-test-backend + cancel-in-progress: false +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Unit tests and vet (no private broker credentials) + run: docker build --target test -t mqtt5-test-backend-ci:${{ gitea.sha }} . + deploy: + needs: test + if: gitea.event_name != 'pull_request' && gitea.ref == 'refs/heads/main' + runs-on: ubuntu-latest + container: + volumes: + - /opt/test-mqtt-5/backend:/runtime + steps: + - uses: actions/checkout@v4 + - name: Build and deploy on the existing server Docker daemon + env: + COMMIT_SHA: ${{ gitea.sha }} + MQTT5_RUNTIME: /runtime + run: | + set -eu + TAG="$(date -u +%Y-%m-%d_%H-%M-%S)-$(printf '%s' "$COMMIT_SHA" | cut -c1-12)" + docker build -t "mqtt5-test-backend:$TAG" . + sh server/deploy.sh "$TAG" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..221440d --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +/bin/ +/.cache/ +/config.local.toml +/*.local.toml +/.env +/.env.* +/secrets/ +/certs/ +/data/ +*.key +*.pem +*.p12 +*.pfx +*.test +coverage.out + +# Shareable ESP32 kit intentionally contains test credentials. +/esp32-test-kit/ +/esp32-test-kit.zip + +/docs/ESP32-DEVELOPER-HANDOFF.md + +/.idea/ +/server/.env +/server/secrets/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..433e0ce --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM golang:1.27.1-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/test-mqtt5 ./cmd/test-mqtt5 \ + && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/device-sim ./cmd/device-sim + +FROM build AS test +RUN go test ./... && go vet ./... + +FROM alpine:3.22 +RUN addgroup -S -g 10001 app && adduser -S -D -H -u 10001 -G app app \ + && apk add --no-cache ca-certificates +COPY --from=build /out/test-mqtt5 /test-mqtt5 +COPY --from=build /out/device-sim /device-sim +USER app:app +ENTRYPOINT ["/test-mqtt5"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..0b37409 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# Robot Market MQTT 5 test environment + +Status: **Sprints 1–4 implemented; local tests pass. Functional testing now uses raw observation and unrestricted broker topics.** The Go MQTT 5 observer, command CLI, simulator and optional loopback browser viewer are available. Remote TLS/plaintext MQTT checks remain available for the observer and simulator. + +This independent project will test a local Go backend and simulator against an isolated Mosquitto broker. The intended remote topology requires only outbound connections from the development computer and devices. + +The user authorized Sprints 1–4 on 2026-10-06 and requested a server-deployable broker with TLS plus optional plaintext access. Existing projects remain read-only references. Continue one sprint at a time with verification and authorization for the next sprint. + +- [Complete plan](docs/PLAN.md): findings, architecture, dependencies, contracts, security, logging/browser visibility, compatibility, deployment, and verification. +- [Sprint breakdown and implementation prompts](docs/SPRINTS.md): index linking seven separate sprint files, each with a copy-ready prompt and mandatory end-of-sprint tests, checks, fixes, and evidence reporting. + +## Intended experience + +1. Deploy the isolated test broker on the server, then start the local Go backend. +2. Start the simulator, then optionally a dedicated physical test device. +3. Read structured logs showing inbound/outbound MQTT messages, topic, safe payload preview, protocol properties, request correlation, and outcomes. +4. Open the optional Go-served viewer at `http://127.0.0.1:8080` to inspect the same events, connection state, counters, and compatibility results. +5. Run harmless `ping` or `get_version` commands from the CLI and observe transport acknowledgement, device acceptance, and completion separately. +6. Inspect compatibility evidence separately for simulator and physical devices. + +The broker, offline configuration validation, MQTT observer/command CLI, simulator and browser viewer are implemented. See [the run guide](docs/RUNNING.md) for operation and visible input/output logs. Physical-device verification remains outstanding. + +The Go observer/viewer and simulator also have a local Docker image and Compose setup. The Compose file connects to the already deployed remote test broker; broker deployment remains under `deploy/`. + +On Linux, copy `.env.docker.example` to `.env`, confirm the local CA/credential file paths, then run `docker compose up --build -d observer` and `docker compose --profile simulator up -d simulator`. The viewer opens at . See [Docker run details and commands](docs/RUNNING.md#run-with-docker-compose). + +## Scope boundaries + +- All new implementation belongs in this directory. +- Do not modify the existing vending backend, device repositories, production broker, credentials, listeners, or data. +- No vending, motor, payment, credential-change, or OTA commands. +- Browser viewer is read-only, optional, loopback-bound, and served by Go with embedded HTML/JavaScript; no separate frontend build system is required. +- No database, cloud dashboard, Kubernetes, or monitoring stack. +- Simulator success is never reported as hardware verification. + +Sprint 1 verification is recorded in [its sprint document](docs/sprints/01-foundation.md#completion-evidence). Sprint 2 server runtime and external MQTT connectivity are verified; Sprint 3 [acceptance evidence](docs/sprints/03-backend-simulator.md#completion-evidence) records unit/race, CLI and real-broker checks. Sprint 4 [acceptance evidence](docs/sprints/04-browser-viewer.md#completion-evidence) records viewer tests and the browser verification status. Deployment instructions are available below. + +## ESP32 connection reference + +Share the [ESP32 test ZIP](esp32-test-kit.zip) with the firmware developer. It contains a short [test guide](docs/ESP32-DEVELOPER-HANDOFF.md), actual ESP32 test credentials in plaintext, and the CA for optional TLS. + +The supplied `Esp32MqttConfig` describes Wi-Fi, broker host/port, credentials, subscription/publication topic and CA trust. It is not a payload schema and does not block the Go backend. [The plan](docs/PLAN.md#61-esp32-connection-configuration-supplied-by-user) records the exact struct, field mapping, MQTT 5 selection checks and the single-publication-topic limitation to inspect during device integration. + +## Available now + +```sh +cd /home/ryan/Media/Code/Code/robot-market/test-mqtt-5 +rtk go run ./cmd/test-mqtt5 --config config.example.toml check-config +``` + +For MQTT observer/simulator/command usage, start with [RUNNING.md](docs/RUNNING.md). + +See [configuration and CLI usage](docs/CONFIGURATION.md) and [implemented payload contracts](docs/CONTRACTS.md). `check-config` does not connect to a broker or validate remote credentials/certificates. + +## Server broker deployment package + +- [TLS Compose](deploy/compose.yaml) +- [Optional plaintext override](deploy/compose.insecure.yaml) +- [Deployment/setup/verification instructions](docs/DEPLOYMENT.md) + +Deploy the copied deploy/ directory on your server. The base file publishes TLS on 8884; adding the insecure override also publishes authenticated plaintext on 1884. The setup helper creates dedicated test certificates/credentials. The user deployed the broker; server runtime checks and external TLS/plaintext exchanges passed. + +The Docker viewer subscribes to `robot-market/test/v1/#` and shows raw messages without schema, timestamp or device-ID checks. Any authenticated test account may publish/subscribe to any broker topic. See [the current diagnostic report](docs/DEVICE-MESSAGE-DIAGNOSTICS.md). diff --git a/cmd/device-sim/main.go b/cmd/device-sim/main.go new file mode 100644 index 0000000..1fd0719 --- /dev/null +++ b/cmd/device-sim/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "context" + "os" + "os/signal" + "robot-market/test-mqtt-5/internal/cli" + "syscall" +) + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + code := cli.Run(ctx, os.Args[1:], os.Stdout, os.Stderr, true) + stop() + os.Exit(code) +} diff --git a/cmd/test-mqtt5/main.go b/cmd/test-mqtt5/main.go new file mode 100644 index 0000000..6b9f039 --- /dev/null +++ b/cmd/test-mqtt5/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "context" + "io" + "os" + "os/signal" + "robot-market/test-mqtt-5/internal/cli" + "syscall" +) + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + code := run(ctx, os.Args[1:], os.Stdout, os.Stderr) + stop() + os.Exit(code) +} +func run(ctx context.Context, args []string, out, errOut io.Writer) int { + return cli.Run(ctx, args, out, errOut, false) +} diff --git a/cmd/test-mqtt5/main_test.go b/cmd/test-mqtt5/main_test.go new file mode 100644 index 0000000..b1ded9d --- /dev/null +++ b/cmd/test-mqtt5/main_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCLI(t *testing.T) { + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + if strings.HasPrefix(key, "TEST_MQTT5_") { + t.Setenv(key, "") + os.Unsetenv(key) + } + } + for _, tc := range []struct { + args []string + code int + contains string + }{ + {[]string{"--help"}, 0, "Usage:"}, + {[]string{"check-config"}, 0, "configuration valid"}, + {[]string{"--device-id", "bad/+", "check-config"}, 1, "topics.device_id"}, + {[]string{"--password=SECRET", "check-config"}, 2, "invalid CLI arguments"}, + {[]string{"command", "dangerous"}, 2, "command requires"}, + } { + var out, errOut bytes.Buffer + code := run(context.Background(), tc.args, &out, &errOut) + all := out.String() + errOut.String() + if code != tc.code || !strings.Contains(all, tc.contains) || strings.Contains(all, "SECRET") { + t.Fatalf("code=%d output=%s", code, all) + } + } + p := filepath.Join(t.TempDir(), "test.toml") + if err := os.WriteFile(p, []byte("[logging]\nformat='json'\nlevel='error'\n"), 0600); err != nil { + t.Fatal(err) + } + var out, errOut bytes.Buffer + if code := run(context.Background(), []string{"--config", p, "check-config"}, &out, &errOut); code != 0 || !strings.Contains(out.String(), `"event":"config_valid"`) { + t.Fatalf("JSON mode: %d %s %s", code, out.String(), errOut.String()) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + out.Reset() + errOut.Reset() + if code := run(ctx, []string{"check-config"}, &out, &errOut); code != 1 || !strings.Contains(errOut.String(), "interrupted") { + t.Fatal("cancellation ignored") + } +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..1a2d049 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,76 @@ +name: robot-market-mqtt5-test + +x-local-runtime: &local-runtime + image: robot-market-mqtt5:local + build: + context: . + dockerfile: Dockerfile + network_mode: host + user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}" + init: true + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=16m + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + stop_grace_period: 10s + logging: + driver: local + options: + max-size: 10m + max-file: "3" + +services: + observer: + <<: *local-runtime + environment: + TEST_MQTT5_WEB__ENABLED: "true" + TEST_MQTT5_TOPICS__OBSERVE_ALL: "true" + TEST_MQTT5_LOGGING__RAW_PAYLOADS: "true" + TEST_MQTT5_RECEIPTS__ENABLED: "true" + command: + - --broker + - ${MQTT_BROKER_URL:-tls://109.122.252.129:8884} + - --ca + - /run/test-mqtt/ca.crt + - --credentials + - /run/test-mqtt/backend.json + - --client-id + - rm5-docker-observer + - observe + volumes: + - type: bind + source: ${MQTT_CA_FILE:?Set MQTT_CA_FILE in .env} + target: /run/test-mqtt/ca.crt + read_only: true + - type: bind + source: ${MQTT_BACKEND_CREDENTIALS_FILE:?Set MQTT_BACKEND_CREDENTIALS_FILE in .env} + target: /run/test-mqtt/backend.json + read_only: true + restart: unless-stopped + + simulator: + <<: *local-runtime + profiles: [simulator] + entrypoint: ["/device-sim"] + command: + - --broker + - ${MQTT_BROKER_URL:-tls://109.122.252.129:8884} + - --ca + - /run/test-mqtt/ca.crt + - --credentials + - /run/test-mqtt/simulator.json + - --client-id + - rm5-docker-simulator + volumes: + - type: bind + source: ${MQTT_CA_FILE:?Set MQTT_CA_FILE in .env} + target: /run/test-mqtt/ca.crt + read_only: true + - type: bind + source: ${MQTT_SIM_CREDENTIALS_FILE:?Set MQTT_SIM_CREDENTIALS_FILE in .env} + target: /run/test-mqtt/simulator.json + read_only: true + restart: unless-stopped diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..1edde86 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,65 @@ +# Offline validation: go run ./cmd/test-mqtt5 --config config.example.toml check-config +# Supply real test credentials using environment variables or an ignored password file. +# All relative file paths resolve from the working directory. +[mqtt] +# Plaintext is test-only and requires mqtt:// plus this explicit opt-in. +allow_plaintext = false +url = "tls://localhost:8883" +client_id = "rm5-lab-observer-01" +username = "test-backend" +password = "" +password_file = "" +qos = 1 +clean_start = true +session_expiry = "5m" +keep_alive = "30s" +reconnect_min = "1s" +reconnect_max = "30s" + +[tls] +ca_file = "" +client_cert = "" +client_key = "" + +[topics] +namespace = "robot-market/test/v1" +device_id = "sim-001" +# Observe mode only: add other explicitly ACL-authorized identities. +observe_devices = "" +# Show every topic/payload under the namespace without schema validation. +observe_all = false + +[deadlines] +connect = "10s" +operation = "5s" +command = "15s" +shutdown = "5s" + +[logging] +level = "info" +format = "text" +# Enable to inspect arbitrary test payloads, even failed contracts. +raw_payloads = false + +[limits] +payload_bytes = 16384 +preview_bytes = 4096 +pending_commands = 128 +inbound_queue = 256 +event_history = 1000 +dedup_entries = 1024 +dedup_retention = "10m" + +[simulator] +telemetry_interval = "5s" +ack_delay = "0s" + +[web] +enabled = false +address = "127.0.0.1:8080" +max_viewers = 8 +viewer_queue = 64 + +[receipts] +# Application ACKs for raw device messages; device subscribes to its /acks topic. +enabled = false diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..664ffd9 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,8 @@ +# No credentials belong here. Copy to .env and adjust available server ports. +TLS_BIND_IP=0.0.0.0 +TLS_PORT=8884 +# Used only when compose.insecure.yaml is included. +INSECURE_BIND_IP=0.0.0.0 +INSECURE_PORT=1884 +BROKER_UID=1883 +BROKER_GID=1883 diff --git a/deploy/.gitignore b/deploy/.gitignore new file mode 100644 index 0000000..ce7360d --- /dev/null +++ b/deploy/.gitignore @@ -0,0 +1,6 @@ +.env +certs/ +secrets/ +data/ +backups/ +__pycache__/ diff --git a/deploy/acl b/deploy/acl new file mode 100644 index 0000000..51075f9 --- /dev/null +++ b/deploy/acl @@ -0,0 +1,14 @@ +# Optional ACL equivalent to unrestricted functional testing. +# The default mosquitto.conf does not load an ACL plugin. +user test-backend +topic readwrite # + +user sim-001 +topic readwrite # + +user esp32-lab-001 +topic readwrite # + +user test-health +topic readwrite # + diff --git a/deploy/compose.insecure.yaml b/deploy/compose.insecure.yaml new file mode 100644 index 0000000..1bd7c92 --- /dev/null +++ b/deploy/compose.insecure.yaml @@ -0,0 +1,5 @@ +# Explicit test-only override. TLS remains published as well. +services: + broker: + ports: + - "${INSECURE_BIND_IP:-0.0.0.0}:${INSECURE_PORT:-1884}:1883" diff --git a/deploy/compose.yaml b/deploy/compose.yaml new file mode 100644 index 0000000..9946a90 --- /dev/null +++ b/deploy/compose.yaml @@ -0,0 +1,67 @@ +name: robot-market-mqtt5-test +services: + broker: + image: eclipse-mosquitto:2.1.2-alpine@sha256:38c0da4f2ef84284d47b3b3eeea1cb3bdeabe81ee10caf0cd5c5ff61ee3ea408 + user: "${BROKER_UID:-1883}:${BROKER_GID:-1883}" + entrypoint: ["/usr/sbin/mosquitto"] + command: ["-c", "/mosquitto/config/mosquitto.conf"] + restart: unless-stopped + stop_grace_period: 30s + ports: + - "${TLS_BIND_IP:-0.0.0.0}:${TLS_PORT:-8884}:8883" + volumes: + - type: bind + source: ./mosquitto.conf + target: /mosquitto/config/mosquitto.conf + read_only: true + bind: + create_host_path: false + - type: bind + source: ./acl + target: /mosquitto/config/acl + read_only: true + bind: + create_host_path: false + - type: bind + source: ./secrets/runtime + target: /run/mqtt-secrets + read_only: true + bind: + create_host_path: false + - type: bind + source: ./certs/server + target: /mosquitto/certs + read_only: true + bind: + create_host_path: false + - type: bind + source: ./data + target: /mosquitto/data + bind: + create_host_path: false + - type: bind + source: ./scripts/healthcheck.sh + target: /usr/local/bin/mqtt-healthcheck + read_only: true + bind: + create_host_path: false + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=8m,mode=1777 + - /mosquitto/log:rw,noexec,nosuid,size=1m,mode=1777 + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + mem_limit: 256m + cpus: 0.5 + pids_limit: 64 + logging: + driver: json-file + options: + max-size: "5m" + max-file: "3" + healthcheck: + test: ["CMD", "/bin/sh", "/usr/local/bin/mqtt-healthcheck"] + interval: 30s + timeout: 20s + start_period: 10s + retries: 3 diff --git a/deploy/mosquitto.conf b/deploy/mosquitto.conf new file mode 100644 index 0000000..39023d3 --- /dev/null +++ b/deploy/mosquitto.conf @@ -0,0 +1,34 @@ +# Functional test broker: authenticated clients may publish/subscribe to any topic. +# Both plaintext and TLS listeners share password authentication; no topic ACL. +allow_anonymous false +global_plugin /usr/lib/mosquitto_password_file.so +plugin_opt_password_file /run/mqtt-secrets/passwords + +persistence true +persistence_location /mosquitto/data/ +persistence_file mosquitto.db +autosave_interval 30 +max_queued_messages 256 +max_queued_bytes 4194304 +max_inflight_messages 20 +max_packet_size 32768 +message_size_limit 16384 +global_max_clients 64 +queue_qos0_messages false +log_dest stdout +log_type error +log_type warning +log_type notice +connection_messages true + +listener 8883 +protocol mqtt +certfile /mosquitto/certs/server.crt +keyfile /mosquitto/certs/server.key +# Username/password authentication; clients verify this server's certificate. +require_certificate false +tls_version tlsv1.2 + +# Not published on the host unless the insecure Compose override is included. +listener 1883 +protocol mqtt diff --git a/deploy/scripts/healthcheck.sh b/deploy/scripts/healthcheck.sh new file mode 100644 index 0000000..b0dcc3e --- /dev/null +++ b/deploy/scripts/healthcheck.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# Secrets are read from a protected config file, never process arguments. +set -eu +conf=/run/mqtt-secrets/health.conf +topic=robot-market/test/health/check +token="health-$(date +%s)-$$" +cleanup() { + timeout 4 mosquitto_pub -o "$conf" -h localhost -p 8883 \ + --cafile /mosquitto/certs/ca.crt -V mqttv5 -q 1 -t "$topic" -r -n \ + >/dev/null 2>&1 || true +} +trap cleanup EXIT +# Retain only for the subscribe check so startup scheduling cannot lose the probe. +timeout 5 mosquitto_pub -o "$conf" -h localhost -p 8883 \ + --cafile /mosquitto/certs/ca.crt -V mqttv5 -q 1 -t "$topic" -r -m "$token" \ + >/dev/null 2>&1 || exit 1 +received=$(timeout 7 mosquitto_sub -o "$conf" -h localhost -p 8883 \ + --cafile /mosquitto/certs/ca.crt -V mqttv5 -q 1 -t "$topic" -C 1 -W 5 \ + 2>/dev/null) || exit 1 +[ "$received" = "$token" ] diff --git a/deploy/scripts/prepare.py b/deploy/scripts/prepare.py new file mode 100644 index 0000000..7654ec1 --- /dev/null +++ b/deploy/scripts/prepare.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Prepare test-only credentials/certificates without printing secrets or starting a broker.""" +import argparse +import ipaddress +import json +import os +from pathlib import Path +import re +import secrets +import shutil +import subprocess +import sys + +IMAGE = 'eclipse-mosquitto:2.1.2-alpine@sha256:38c0da4f2ef84284d47b3b3eeea1cb3bdeabe81ee10caf0cd5c5ff61ee3ea408' +USERS = ('test-backend', 'sim-001', 'esp32-lab-001', 'test-health') + + +def run(args): + result = subprocess.run(args, capture_output=True, text=True, timeout=180) + if result.returncode: + # OpenSSL/CLI errors can contain paths or config values. Keep raw output private. + raise RuntimeError(f'{args[0]} failed; check tool availability, Docker access and image availability') + + +def valid_host(value): + try: + ipaddress.ip_address(value) + return value + except ValueError: + if len(value) > 253 or not all(re.fullmatch(r'[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?', part) for part in value.split('.')): + raise argparse.ArgumentTypeError('expected a DNS hostname or IP address, without scheme/port') + return value + + +def write(path, data, mode=0o600): + with path.open('x') as stream: + stream.write(data) + path.chmod(mode) + + +def prepare(root, host, uid, gid): + root.mkdir(parents=True, exist_ok=True) + for name in ('certs', 'secrets', 'data', '.env'): + if (root / name).exists() or (root / name).is_symlink(): + raise RuntimeError(f'{name} already exists; refusing to replace existing credentials, certificates or data') + if os.geteuid() != 0 and (uid != os.getuid() or gid != os.getgid()): + raise RuntimeError('run with sudo for UID/GID 1883, or supply --owner with your own UID:GID for isolated artifact tests') + for tool in ('docker', 'openssl'): + if not shutil.which(tool): + raise RuntimeError(f'{tool} is required') + # All following generated content is inside the explicitly selected deployment root. + os.umask(0o077) + authority = root / 'certs' / 'authority' + server = root / 'certs' / 'server' + runtime = root / 'secrets' / 'runtime' + clients = root / 'secrets' / 'clients' + for path in (authority, server, runtime, clients, root / 'data'): + path.mkdir(parents=True) + run(['openssl', 'req', '-x509', '-newkey', 'rsa:3072', '-nodes', '-sha256', '-days', '365', + '-subj', '/CN=Robot Market TEST CA', '-addext', 'basicConstraints=critical,CA:TRUE', + '-addext', 'keyUsage=critical,keyCertSign,cRLSign', + '-keyout', str(authority / 'ca.key'), '-out', str(server / 'ca.crt')]) + try: + ipaddress.ip_address(host) + host_san = f'IP:{host}' + except ValueError: + host_san = f'DNS:{host}' + write(server / 'server.ext', 'basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\nsubjectAltName=' + host_san + ',DNS:localhost,IP:127.0.0.1\n') + run(['openssl', 'req', '-new', '-newkey', 'rsa:3072', '-nodes', '-sha256', '-subj', '/CN=' + host, + '-keyout', str(server / 'server.key'), '-out', str(server / 'server.csr')]) + run(['openssl', 'x509', '-req', '-sha256', '-days', '90', '-in', str(server / 'server.csr'), + '-CA', str(server / 'ca.crt'), '-CAkey', str(authority / 'ca.key'), '-CAcreateserial', + '-extfile', str(server / 'server.ext'), '-out', str(server / 'server.crt')]) + run(['openssl', 'verify', '-CAfile', str(server / 'ca.crt'), str(server / 'server.crt')]) + passwords = {user: secrets.token_urlsafe(32) for user in USERS} + # Hash a disposable file; never pass passwords as command arguments. + raw = runtime / 'passwords.pending' + write(raw, ''.join(f'{user}:{password}\n' for user, password in passwords.items())) + try: + run(['docker', 'run', '--rm', '--network', 'none', '--user', f'{os.getuid()}:{os.getgid()}', + '--mount', f'type=bind,src={runtime.resolve()},dst=/setup', + '--entrypoint', '/usr/bin/mosquitto_passwd', IMAGE, '-U', '/setup/passwords.pending']) + # Check conversion without emitting hashes or passwords. + entries = raw.read_text().splitlines() + if len(entries) != len(USERS) or any(not line.split(':', 1)[1].startswith('$') for line in entries): + raise RuntimeError('password hashing did not produce the expected output') + raw.rename(runtime / 'passwords') + finally: + raw.unlink(missing_ok=True) + write(runtime / 'health.conf', '-u test-health\n-P ' + passwords['test-health'] + '\n') + for user in USERS: + if user == 'test-health': + continue + write(clients / (user + '.json'), json.dumps({'host': host, 'tls_port': 8884, 'plaintext_port': 1884, + 'username': user, 'password': passwords[user], 'device_id': user if user != 'test-backend' else None}, indent=2) + '\n') + for path in (root / 'certs', server, root / 'secrets', runtime): + os.chown(path, os.getuid(), gid) + path.chmod(0o750) + for path in (*server.iterdir(), *runtime.iterdir()): + os.chown(path, os.getuid(), gid) + path.chmod(0o640) + # The CA signing key stays private and is never mounted into the broker. + authority.chmod(0o700) + (authority / 'ca.key').chmod(0o600) + os.chown(root / 'data', uid, gid) + (root / 'data').chmod(0o750) + write(root / '.env', f'TLS_BIND_IP=0.0.0.0\nTLS_PORT=8884\nINSECURE_BIND_IP=0.0.0.0\nINSECURE_PORT=1884\nBROKER_UID={uid}\nBROKER_GID={gid}\n', 0o644) + print('Prepared test certificates, hashed broker passwords and private client handoff files.') + print('No broker was started. Edit .env for available ports; client JSON ports must match your edits.') + print('Client secrets: secrets/clients/ (do not commit or paste into public logs). Public CA: certs/server/ca.crt') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--host', required=True, type=valid_host, help='actual DNS name or IP clients will use') + parser.add_argument('--owner', default='1883:1883', help='broker UID:GID (default 1883:1883)') + parser.add_argument('--output', type=Path, default=Path(__file__).resolve().parents[1]) + args = parser.parse_args() + try: + uid, gid = map(int, args.owner.split(':')) + if not (1 <= uid <= 2**31-1 and 1 <= gid <= 2**31-1): + raise ValueError() + except ValueError: + parser.error('--owner must be non-root numeric UID:GID') + try: + prepare(args.output.resolve(), args.host, uid, gid) + except (OSError, RuntimeError, subprocess.TimeoutExpired) as error: + detail = str(error) if isinstance(error, RuntimeError) else 'a file operation or bounded tool execution failed' + print('Preparation failed: ' + detail + '. Preserve partial output and inspect privately before retrying.', file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/deploy/scripts/verify.py b/deploy/scripts/verify.py new file mode 100644 index 0000000..852b025 --- /dev/null +++ b/deploy/scripts/verify.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Verify the dedicated deployed broker using bounded clients; never print passwords.""" +import argparse +import json +import os +import re +from pathlib import Path +import subprocess +import sys +import tempfile +import time +import uuid + +from prepare import IMAGE, USERS + +ROOT = Path(__file__).resolve().parents[1] + + +def invoke(args, timeout=30): + return subprocess.run(args, capture_output=True, text=True, timeout=timeout) + + +def publication_result(result): + """Use the MQTT 5 PUBACK, not the CLI exit code, for broker acceptance.""" + if result.returncode: + return result + acknowledgements = re.findall(r'received PUBACK \(Mid: \d+, RC:\s*(\d+)\)', result.stdout + result.stderr) + if len(acknowledgements) != 1: + raise RuntimeError('publish acknowledgement missing or ambiguous; check inconclusive') + reason = int(acknowledgements[0]) + return subprocess.CompletedProcess(result.args, reason if reason >= 128 else 0, + result.stdout, result.stderr) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--insecure', action='store_true', help='also test the internal plaintext listener') + parser.add_argument('--restart', action='store_true', help='gracefully restart THIS test broker to check persistence') + args = parser.parse_args() + compose = ['docker', 'compose', '--project-directory', str(ROOT), '-f', str(ROOT / 'compose.yaml')] + if args.insecure: + compose += ['-f', str(ROOT / 'compose.insecure.yaml')] + try: + if invoke(compose + ['config', '--quiet']).returncode: + raise RuntimeError('Compose validation failed') + result = invoke(compose + ['ps', '-q', 'broker']) + container = result.stdout.strip() + if result.returncode or not container or len(container.splitlines()) != 1: + raise RuntimeError('one running test broker is required') + if not all(c in '0123456789abcdef' for c in container): + raise RuntimeError('invalid container ID') + check = invoke(compose + ['exec', '-T', 'broker', '/bin/sh', '/usr/local/bin/mqtt-healthcheck']) + if check.returncode: + raise RuntimeError('authenticated TLS health exchange failed') + print('Authenticated TLS publish/subscribe health check: PASS') + with tempfile.TemporaryDirectory(prefix='mqtt5-verify-') as folder: + temp = Path(folder) + for user in USERS: + if user == 'test-health': + continue + record = json.loads((ROOT / 'secrets' / 'clients' / (user + '.json')).read_text()) + password = record['password'] + if record['username'] != user or not password or '\n' in password or '\r' in password: + raise RuntimeError('invalid credential handoff file') + (temp / (user + '.conf')).write_text(f'-u {user}\n-P {password}\n') + (temp / 'anonymous.conf').write_text('') + (temp / 'wrong.conf').write_text('-u test-backend\n-P invalid-test-password\n') + for path in temp.iterdir(): + path.chmod(0o600) + # A second unrelated test CA proves trust rejection; no existing CA is modified. + if invoke(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', + '-subj', '/CN=Untrusted TEST CA', '-keyout', str(temp / 'wrong.key'), + '-out', str(temp / 'wrong.crt')]).returncode: + raise RuntimeError('unable to generate negative-test CA') + temp.joinpath('wrong.crt').chmod(0o644) + + def client(binary, user, options, plaintext=False, host='localhost', wrong_ca=False): + base = ['docker', 'run', '--rm', '--network', 'container:' + container, + '--user', f'{os.getuid()}:{os.getgid()}', '--read-only', '--cap-drop', 'ALL', + '--security-opt', 'no-new-privileges', + '--mount', f'type=bind,src={temp},dst=/checks,readonly', + '--mount', f'type=bind,src={ROOT / "certs" / "server" / "ca.crt"},dst=/ca.crt,readonly', + '--entrypoint', '/usr/bin/' + binary, IMAGE, + '-o', '/checks/' + user + '.conf', '-h', host, + '-p', '1883' if plaintext else '8883', '-V', 'mqttv5', '-q', '1'] + base += ['--no-tls'] if plaintext else ['--cafile', '/checks/wrong.crt' if wrong_ca else '/ca.crt'] + result = invoke(base + (['-d'] if binary == 'mosquitto_pub' else []) + options, timeout=15) + if result.returncode in (125, 126, 127): + raise RuntimeError('Docker client container could not run; not a verified MQTT denial') + return publication_result(result) if binary == 'mosquitto_pub' else result + + token = 'verify-' + uuid.uuid4().hex + topic = 'robot-market/test/v1/esp32-lab-001/status' + retained = False + session_id = 'rm5-check-' + uuid.uuid4().hex[:16] + try: + publish = ['-t', topic, '-r', '-m', token] + if client('mosquitto_pub', 'esp32-lab-001', publish).returncode: + raise RuntimeError('authorized device publish failed') + retained = True + result = client('mosquitto_sub', 'test-backend', ['-t', topic, '-C', '1', '-W', '4']) + if result.returncode or result.stdout.strip() != token: + raise RuntimeError('authorized backend receive failed') + print('Device publication and backend reception: PASS') + for name, user, kwargs in ( + ('Anonymous authentication', 'anonymous', {}), + ('Invalid credentials', 'wrong', {}), + ('Untrusted CA', 'test-backend', {'wrong_ca': True}), + ('Wrong hostname', 'test-backend', {'host': '127.0.0.2'}), + ): + result = client('mosquitto_sub', user, ['-t', topic, '-C', '1', '-W', '3'], **kwargs) + if not result.returncode or result.stdout.strip() == token: + raise RuntimeError(name + ' unexpectedly succeeded') + # Denials must not simply be a subscriber timeout on a working connection. + if result.returncode == 27: + raise RuntimeError(name + ' was inconclusive (receive timeout)') + print(name + ' rejection: PASS') + result = client('mosquitto_pub', 'sim-001', ['-t', topic, '-m', token]) + if result.returncode: + raise RuntimeError('functional cross-device publish failed') + print('Unrestricted cross-device publication: PASS') + result = client('mosquitto_sub', 'sim-001', ['-t', topic, '-C', '1', '-W', '3']) + if result.returncode or result.stdout.strip() != token: + raise RuntimeError('functional cross-device read failed') + print('Unrestricted cross-device reception: PASS') + if args.insecure: + result = client('mosquitto_sub', 'test-backend', ['-t', topic, '-C', '1', '-W', '3'], plaintext=True) + if result.returncode or result.stdout.strip() != token: + raise RuntimeError('plaintext authenticated exchange failed') + print('Optional plaintext authenticated reception: PASS') + if args.restart: + telemetry = 'robot-market/test/v1/sim-001/telemetry' + result = client('mosquitto_sub', 'test-backend', ['-t', telemetry, '-c', '-x', '300', '-i', session_id, '-E']) + if result.returncode: + raise RuntimeError('persistent subscription setup failed') + if invoke(compose + ['restart', 'broker'], timeout=45).returncode: + raise RuntimeError('test broker restart failed') + for attempt in range(10): + if invoke(compose + ['exec', '-T', 'broker', '/bin/sh', '/usr/local/bin/mqtt-healthcheck']).returncode == 0: + break + time.sleep(1) + else: + raise RuntimeError('broker health did not recover') + result = client('mosquitto_sub', 'test-backend', ['-t', topic, '-C', '1', '-W', '4']) + if result.returncode or result.stdout.strip() != token: + raise RuntimeError('retained status not preserved') + if client('mosquitto_pub', 'sim-001', ['-t', telemetry, '-m', token]).returncode: + raise RuntimeError('offline session queue publish failed') + result = client('mosquitto_sub', 'test-backend', ['-t', telemetry, '-c', '-x', '0', '-i', session_id, '-C', '1', '-W', '4']) + if result.returncode or result.stdout.strip() != token: + raise RuntimeError('persistent subscription/queued message not restored') + print('Graceful restart retained/session persistence: PASS (not crash durability)') + finally: + if retained: + cleanup = client('mosquitto_pub', 'esp32-lab-001', ['-t', topic, '-r', '-n']) + if cleanup.returncode: + print('WARNING: verification retained status cleanup failed', file=sys.stderr) + print('Server-container checks complete. External DNS/firewall/hostname reachability still needs a client check.') + return 0 + except (OSError, ValueError, KeyError, RuntimeError, subprocess.TimeoutExpired) as error: + # All RuntimeError messages above are controlled and contain no credential values. + message = str(error) if isinstance(error, RuntimeError) else 'missing prerequisites/files or a bounded operation failed' + print('Verification FAILED/BLOCKED: ' + message, file=sys.stderr) + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/docs/BACKEND-SERVER.md b/docs/BACKEND-SERVER.md new file mode 100644 index 0000000..cea7f91 --- /dev/null +++ b/docs/BACKEND-SERVER.md @@ -0,0 +1,45 @@ +# Backend deployment and Gitea Actions + +Repository: `ssh://git@git.my-rm.com:2222/Robot-Market/mqtt-5-test.git`. Deployment branch: `main`. Target: existing test server `109.122.252.129`, separate Compose project `mqtt5-test-backend`, runtime `/opt/test-mqtt-5/backend`. Broker remains in `/opt/test-mqtt-5/deploy`. + +The backend uses TLS to the existing broker, accepts raw test messages and sends receipt ACKs. No simulator is deployed. Private credentials and CA are provisioned on the server; never include ESP32 ZIP, private JSON, local .env or certificates/keys in Git. Event history is in memory and resets when the backend restarts. + +## Access the UI + +Run on your local computer and keep it running: + +```sh +ssh -N -L 8080:127.0.0.1:18080 root@109.122.252.129 +``` + +Open http://127.0.0.1:8080. If local port 8080 is occupied, use `-L 8081:127.0.0.1:18080` and open http://127.0.0.1:8081. The server listens only on 127.0.0.1:18080. No public firewall port is required. Stop a local receipt observer while using this server backend to avoid two independent receipt ACK publishers. + +## CI/CD + +`.gitea/workflows/backend.yml` runs on main pushes, main pull requests and manual dispatch. The test job builds the Docker test target (unit tests plus vet); remote integration tests are opt-in and skipped without private handoffs. The deploy job runs only on main, never for a PR. It builds an immutable UTC timestamp plus commit tag, then runs `server/deploy.sh`. No registry or SSH deployment secret is needed: the existing ubuntu-latest runner uses this server's Docker socket. + +The runner configuration allows `/opt/test-mqtt-5/backend` in `container.valid_volumes`; only the deploy job requests that mount as `/runtime`. The prior runner configuration is backed up before this addition. Existing frontend job mounts remain unchanged. This workflow assumes that label continues to use the same server Docker daemon; moving the runner requires updating the deployment arrangement. + +`deploy.sh` locks the runtime directory, validates Compose, preserves the previous image-tag file and Compose, deploys only backend, and checks container health for up to 60 seconds. Health requires the UI snapshot to report MQTT connected. Failed updates restore the previous release; a failed first deployment stops the backend. Images are retained for rollback. No host-wide image prune is run. Remote device return-path checks should still be performed after significant MQTT changes. + +## Server operations + +Standard Linux commands on the server: + +```sh +cd /opt/test-mqtt-5/backend +docker compose --env-file .env -f compose.yaml ps +docker compose --env-file .env -f compose.yaml logs -f backend +docker compose --env-file .env -f compose.yaml restart backend +``` + +Manual rollback to the preserved release: + +```sh +cd /opt/test-mqtt-5/backend +cp .env.previous .env +cp compose.previous.yaml compose.yaml +docker compose --env-file .env -f compose.yaml up -d --force-recreate backend +``` + +During deployments do not run a simultaneous manual rollback. Before changing broker credentials, update the server's private `secrets/backend.json` (root:10001, mode 0640, parent directory 0750) and restart backend. Do not rotate the broker's existing credentials as part of ordinary backend deployments. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..2439c4e --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,73 @@ +# Configuration and offline CLI + +Requires Go 1.27.1. The CLI supports offline `check-config`, MQTT `observe`, and `command ping|get_version`. The device simulator is available separately; see [RUNNING.md](RUNNING.md) for TLS/plaintext commands and visible message flow. An optional read-only HTTP/SSE viewer is available in observe mode; see [RUNNING.md](RUNNING.md). + +```sh +rtk go run ./cmd/test-mqtt5 --help +rtk go run ./cmd/test-mqtt5 --config config.example.toml check-config +rtk go run ./cmd/test-mqtt5 --config config.example.toml --device-id lab-001 --log-level debug check-config +rtk go build -buildvcs=false -o bin/test-mqtt5 ./cmd/test-mqtt5 +``` + +Flags precede the subcommand. `-buildvcs=false` avoids stamping errors when this folder sits inside an unavailable/incomplete parent Git checkout. It does not alter Git state. + +## Precedence and environment mapping + +Defaults < optional TOML file < environment < explicitly supplied flags. No file is loaded automatically. An explicit missing/malformed file fails. Unknown keys and invalid types fail, including in empty TOML tables. Relative file paths resolve against the process working directory. + +Every key in [config.example.toml](../config.example.toml) has an environment equivalent: prepend `TEST_MQTT5_`, uppercase, and replace the dot between section/key with `__`. Preserve single underscores in field names. + +| TOML key | Environment name | Example | +| --- | --- | --- | +| mqtt.url | TEST_MQTT5_MQTT__URL | tls://localhost:8883 | +| mqtt.client_id | TEST_MQTT5_MQTT__CLIENT_ID | rm5-lab-observer-01 | +| mqtt.password | TEST_MQTT5_MQTT__PASSWORD | Provide a test-only secret outside tracked files | +| mqtt.password_file | TEST_MQTT5_MQTT__PASSWORD_FILE | secrets/backend-password | +| mqtt.session_expiry | TEST_MQTT5_MQTT__SESSION_EXPIRY | 5m | +| tls.ca_file | TEST_MQTT5_TLS__CA_FILE | certs/ca.pem | +| topics.device_id | TEST_MQTT5_TOPICS__DEVICE_ID | sim-001 | +| logging.format | TEST_MQTT5_LOGGING__FORMAT | json | +| simulator.ack_delay | TEST_MQTT5_SIMULATOR__ACK_DELAY | 0s | +| web.enabled | TEST_MQTT5_WEB__ENABLED | false | + +All fields and defaults are listed in the example TOML. Durations must be strings with units (`0s`, `5s`, `10m`); bare numbers and overflowing values fail. Booleans accept true/false. Integers reject fractional values. Empty environment values override previous values, including clearing a password; nonempty password and password_file cannot be combined. + +## Validation limits + +- Broker URL defaults to verified `tls://host:port`. Test-only `mqtt://host:port` requires `mqtt.allow_plaintext=true` (or --allow-plaintext). Port is 1–65535; embedded credentials/path/query/fragment are prohibited. +- Device/client IDs and namespace segments use safe identifiers; namespace remains under robot-market/test/v1. +- QoS is 0 or 1. Session expiry is whole seconds, 0–4294967295; keepalive is whole seconds, 1–65535. +- Positive operation/connection/shutdown intervals are bounded; reconnect_max is at most 30s and >= reconnect_min. +- Command lifetime is whole seconds, 1–60; dedup retention must exceed it. +- Payload limit is 256–16384 bytes; preview is 64–4096 and no larger than payload limit. +- Pending/queue/history/dedup capacities are 1–10000; viewer count is 1–8. +- Viewer address is 127.0.0.1 with a valid port. The viewer binds only to 127.0.0.1; max viewers is 1–8 and each viewer queue is 1–256 events. +- Certificate/key must be paired. Configured secret/certificate paths must reference readable, nonempty regular files up to 1 MiB. + +## Output and limitations + +Success uses configured text or JSON slog output, with `event=config_valid`, `network_checked=false` and `credentials_checked=false`. Success remains visible at the configured level, including warn/error. Invalid configuration/CLI errors go to stderr with exit 1/2 and omit supplied values. Help exits 0. Cancellation exits 1. + +Empty test passwords are allowed for offline validation. This check does not load/validate password-file contents, parse certificates, establish trust, verify the broker hostname, authenticate, or test MQTT compatibility. Runtime modes parse certificates, authenticate, and check MQTT transport acknowledgements. CA paths are optional; TLS uses system trust plus a configured test CA, with hostname verification always enabled. + +Keep real credentials in ignored config.local.toml, environment or secrets files. Never log a complete Config. Default previews of MQTT payloads are allowlisted as described in CONTRACTS.md. + +## Checks + +```sh +rtk gofmt -l cmd internal +rtk go vet ./... +rtk go test ./... +rtk go test -race ./... +``` + +During Sprint 1 verification, module/build caches used `/tmp/test-mqtt5-mod` and `/tmp/test-mqtt5-build`; these are temporary local caches, not required for ordinary usage. + +Additional test observation settings: + +- `topics.observe_devices`: comma-separated additional device IDs, maximum 16; observe mode only. Each device is subscribed using exact category topics. `topics.device_id` remains the target for single-device command mode. +- `logging.raw_payloads`: false by default; enables bounded raw MQTT payload display in logs and browser for test diagnostics. Docker observer explicitly enables it. Invalid contracts still produce validation errors and never imply successful compatibility. + +`topics.observe_all=true` is observe-mode-only: subscribe to `/#`, record raw payloads and skip all JSON contract validation. Compose enables this setting for functional testing. + +`receipts.enabled=true` enables asynchronous backend receipt ACK publication in raw observe mode only. The reply queue uses limits.inbound_queue; publication uses deadlines.operation. It does not introduce JSON admission checks. diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md new file mode 100644 index 0000000..8d19fda --- /dev/null +++ b/docs/CONTRACTS.md @@ -0,0 +1,76 @@ +# Test message contracts + +Sprint 1 implements structural validation and safe inspection previews. Sprint 3 implements network exchange, native MQTT properties, request tracking and duplicate execution handling. These contracts are independent of the ESP32 connection configuration. + +## Topics and common fields + +Topics: `robot-market/test/v1/{device_id}/{telemetry|status|commands|acks}`. The namespace may have additional safe lab segments under `robot-market/test/v1`. + +Every payload is a JSON object with `schema_version: 1` and a `device_id` matching the topic. IDs contain 1–64 letters/digits/underscores/hyphens, beginning with a letter/digit. Timestamps use RFC3339 UTC ending in `Z`. Maximum payload is 16 KiB. Unknown fields, duplicate keys (including case aliases), nulls, arrays, invalid UTF-8, trailing JSON and excessive nesting are rejected. Validation errors exclude raw values. + +| Category | Additional required fields | Rules | +| --- | --- | --- | +| telemetry | message_id, boot_id, sequence, timestamp, readings | Sequence is an unsigned integer, including zero. Readings has 1–16 safe-name finite numeric values. | +| status | boot_id, state and one time field | State is online/offline. Use timestamp for explicit publication, or will_configured_at for a preconfigured offline Will. Never both. | +| commands | request_id, issued_at, expires_at, command | Only ping/get_version. Lifetime is 1–60 seconds. | +| acks | request_id, timestamp, stage, ok | ok must be an explicit boolean. Stage rules below. | + +Status optionally reports implementation and reported_protocol (`5` or `3.1.1`). Reported protocol is self-reported, not compatibility proof. + +## Examples + +Telemetry: + +```json +{"schema_version":1,"device_id":"sim-001","message_id":"evt-001","boot_id":"boot-001","sequence":0,"timestamp":"2026-10-06T10:00:00Z","readings":{"uptime_s":12}} +``` + +Status: + +```json +{"schema_version":1,"device_id":"sim-001","boot_id":"boot-001","state":"online","timestamp":"2026-10-06T10:00:00Z","implementation":"go-simulator","reported_protocol":"5"} +``` + +Command: + +```json +{"schema_version":1,"device_id":"sim-001","request_id":"req-001","issued_at":"2026-10-06T10:00:00Z","expires_at":"2026-10-06T10:00:15Z","command":"ping"} +``` + +Acceptance: + +```json +{"schema_version":1,"device_id":"sim-001","request_id":"req-001","timestamp":"2026-10-06T10:00:01Z","stage":"accepted","ok":true} +``` + +Completion: + +```json +{"schema_version":1,"device_id":"sim-001","request_id":"req-001","timestamp":"2026-10-06T10:00:01Z","stage":"completed","ok":true,"result":{"pong":true}} +``` + +Failure: + +```json +{"schema_version":1,"device_id":"sim-001","request_id":"req-001","timestamp":"2026-10-06T10:00:15Z","stage":"rejected","ok":false,"error":{"code":"expired","message":"command deadline passed"}} +``` + +Accepted requires ok=true with no result/error. Completed requires ok=true and exactly one result: pong=true or a bounded version identifier. Rejected/failed requires ok=false, a safe error code and a 1–256-byte error message, without result. Error messages are always redacted in inspection previews because they can contain arbitrary data. + +## Deadline and acknowledgement semantics + +`Command.Validate` checks structure and lifetime. `CheckDeadline(now)` additionally rejects commands before issued_at and at/after expires_at. Historical examples remain structurally valid even after their deadline. Device clocks must be synchronized for execution checks. + +Transport acknowledgement, application acceptance and device completion are separate. The structural decoder validates ACK shape. The Sprint 3 backend additionally matches active own requests, command-specific results and native correlation; simulator deduplication and transport properties are covered by local/remote tests. Broader expiry/fault and physical-device verification remain later sprints. JSON request_id is not a native MQTT property. + +## Safe inspection + +`events.SafePreview` decodes and validates before rendering known fields. Unknown/sensitive fields, invalid JSON and binary payloads cause complete preview omission; payload length remains visible. Free-form ACK error messages become `[redacted]`. Preview output is at most the requested limit, capped at 4 KiB, and has an explicit truncation flag. Truncated text is display text, not necessarily valid JSON. + +The event model provides direction, topic, identities, QoS, retain/DUP, properties, lifecycle kind, request ID and attributable latency. The implemented transport bounds and sanitizes property metadata before creating events; do not attach raw broker user properties or credentials. + +## Native MQTT 5 transport (Sprint 3) + +Commands use non-retained QoS 1 with Response Topic equal to the exact device ACK topic, bounded Correlation Data and MQTT Message Expiry alongside the JSON application deadline. Backend command mode uses the request ID bytes as native correlation. The simulator accepts opaque nonempty correlation values up to 64 bytes and echoes them unchanged on every ACK. ACK publications are non-retained QoS 1 and have no Response Topic. Pending completion requires matching device, topic, JSON request ID, native correlation and command-specific result; retained ACKs are rejected. Completed may arrive before accepted without regressing state. + +Dedup identity includes canonical command JSON and native correlation. In-progress duplicates replay acceptance, completed duplicates replay the cached terminal ACK, and conflicting reuse is rejected without execution. Cache retention is bounded and memory-only. MQTT transport acknowledgement is separate from application admission and execution; dropped input, missing completion and connection loss cannot be reported as execution success. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..1b21708 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,162 @@ +# Deploy the isolated test broker on your server + +This package is ready for operator deployment. Compose, setup generation and Mosquitto configuration were checked locally. The user deployed the broker; server authentication/ACL/graceful-persistence checks and external TLS/plaintext MQTT exchanges passed on 2026-10-06. + +The user requested server deployment artifacts and an optional plaintext connection alongside TLS. Both listeners require test credentials. Topic ACLs are disabled for functional testing: every authenticated account can publish and subscribe to any topic. Plaintext sends credentials/messages without encryption; expose that test port only to intended test clients. + +## Files to copy + +Copy the project's `deploy/` directory to a separate server directory, for example `/opt/robot-market-mqtt5-test`. Do not copy existing production credentials/configuration. The scripts require Linux, Docker Engine, Docker Compose, Python 3 and OpenSSL. All deployment commands use standard Linux tools. + +The official image is pinned to: + +```text +eclipse-mosquitto:2.1.2-alpine@sha256:38c0da4f2ef84284d47b3b3eeea1cb3bdeabe81ee10caf0cd5c5ff61ee3ea408 +``` + +This immutable multi-platform index includes Linux amd64, ARM64, ARMv6, 386, ppc64le and s390x. Confirm your server matches a supported platform. The originally proposed `2.1.2` tag is absent; `2.1.2-alpine` is the verified official tag. [Official image metadata](https://github.com/docker-library/official-images/blob/master/library/eclipse-mosquitto) + +Commands below run from the copied deployment directory, not the Go project root: + +```sh +cd /opt/robot-market-mqtt5-test +docker compose version +docker compose pull +sudo python3 scripts/prepare.py --host mqtt-test.example.com +``` + +Replace `mqtt-test.example.com` with the exact server DNS name **or public IP** your clients will use. Do not include a scheme or port. The helper makes a dedicated private test CA and a 90-day server certificate with that name/IP plus localhost/127.0.0.1 SANs. A client connecting under a different name must fail hostname verification. + +Preparation generates random test passwords, hashes them using the pinned image's `mosquitto_passwd`, and never prints them or passes them in process arguments. It runs a network-disabled disposable hashing container; it does not start the broker. It refuses to overwrite `.env`, `certs/`, `secrets/` or `data/`. If it fails after partial generation, inspect privately and use a fresh dedicated directory rather than overwriting unknown state. + +Default runtime owner is numeric UID/GID 1883:1883. The helper sets broker-readable files to owner/group-restricted modes and gives the data directory to that UID. CA signing keys remain outside the broker mounts. Running preparation with sudo is needed to assign that ownership on a normal server. Rootless Docker/user-namespace mappings require deliberate permission adaptation; they have not been verified here. + +## Select ports and start + +Edit the generated `.env` for available server ports. Default host ports intentionally avoid common production ports: + +| Transport | Default server port | Exposure | +| --- | --- | --- | +| MQTT over TLS | 8884 -> container 8883 | Base Compose file | +| Plaintext MQTT | 1884 -> container 1883 | Only with insecure override | + +The base file publishes TLS on IPv4 `0.0.0.0`. To limit exposure, set TLS_BIND_IP to the appropriate server interface. The broker's internal plaintext listener is available to containers on its Docker network; the base file does not publish it on the server host. + +TLS only: + +```sh +docker compose config --quiet +docker compose run --rm --no-deps broker --test-config -c /mosquitto/config/mosquitto.conf +docker compose up -d +``` + +TLS plus plaintext, as requested: + +```sh +docker compose -f compose.yaml -f compose.insecure.yaml config --quiet +docker compose -f compose.yaml -f compose.insecure.yaml run --rm --no-deps broker --test-config -c /mosquitto/config/mosquitto.conf +docker compose -f compose.yaml -f compose.insecure.yaml up -d +``` + +Use the same file combination for subsequent operations. `config --quiet` avoids printing resolved configuration. Passwords are file-mounted, not Compose environment variables. All application payloads still need validation regardless of transport. + +Allow the chosen TLS port through the provider firewall/security group and server firewall. If using plaintext, allow 1884 only from intended test client IPs. Docker-published ports may bypass ordinary UFW rules; use provider rules or Docker-aware filtering (DOCKER-USER/nftables appropriate to your Docker setup) and verify from an external client. Do not edit existing broker listeners or expose 1883/8883 merely because they are common defaults. + +## Credentials and topics + +Private generated files (read locally on the server, do not commit): + +| File | Purpose | +| --- | --- | +| secrets/clients/test-backend.json | Dedicated Go backend account | +| secrets/clients/sim-001.json | Simulator/device test identity sim-001 | +| secrets/clients/esp32-lab-001.json | Physical test identity esp32-lab-001 | +| secrets/runtime/passwords | Hashed broker account database | +| secrets/runtime/health.conf | Health-only account in protected CLI config | +| certs/server/ca.crt | Public test CA to copy to clients | +| certs/server/server.key | Broker private key; stays on server | +| certs/authority/ca.key | Private CA signing key; not mounted into broker | + +The Go backend username is `test-backend`; its password is in its JSON handoff file. Device usernames match their test device IDs. **Functional test mode: any authenticated account can publish and subscribe to any topic.** Username and topic device IDs do not need to match. The Mosquitto ACL plugin is not loaded. No password/certificate regeneration is needed when switching an existing test deployment to this mode. + +Both MQTT 3.1.1 and MQTT 5 clients can connect. The browser observer subscribes to `robot-market/test/v1/#` and shows raw payloads without requiring a JSON schema, matching device ID or valid timestamp. + +Copy `ca.crt` and the backend credential handoff through your normal private file-transfer mechanism. When ready, provide the broker hostname/IP, selected TLS/plaintext ports and a path to the private backend credential file plus the public CA on your development computer. There is no need to paste passwords into chat. + +If you changed ports in `.env`, update the port metadata in the client JSON files or explicitly use the correct ports. For the existing C config, mqtt_server/port/user/pass/ca_cert come from these test settings. Wi-Fi remains device-specific. Select the actual C MQTT protocol/library separately. + +The Go backend/simulator support verified TLS and explicit test-only plaintext transport (mqtt.allow_plaintext=true or --allow-plaintext with a mqtt:// URL). See [RUNNING.md](RUNNING.md). TLS hostname/CA verification is never disabled. + +## Verify after deployment + +Before attaching real devices, run: + +```sh +sudo python3 scripts/verify.py --restart +``` + +For the plaintext override: + +```sh +sudo python3 scripts/verify.py --insecure --restart +``` + +The verifier requires a running dedicated test broker. It uses temporary protected client config files and pinned-image CLI containers in that broker's network namespace, with no new host ports. It tests TLS health exchange, authorized device/backend flow, anonymous/bad-password/bad-CA/wrong-host rejection, unrestricted cross-device publication and reception. Optional flags check plaintext and graceful restart retained/session recovery. Commands do not expose real credentials as arguments/output. + +Run it on a quiet test instance: it temporarily sets and clears the retained status for esp32-lab-001 and, with --restart, restarts this broker. It never publishes retained commands. The current functional verifier expects cross-device traffic to succeed. Graceful restart is not crash-durability evidence. A tool failure is not an MQTT denial. The verifier checks MQTT 5 PUBACK reason codes for publishes: Mosquitto 2.1.2 can exit zero after reporting a rejected publish, so an exit code alone is insufficient. Successful publications must receive a non-error PUBACK; missing acknowledgements are inconclusive. The user ran the corrected verifier on 2026-10-06 against the deployed server; all server-container checks passed. External TLS/plaintext authenticated exchanges also passed from the development computer on 2026-10-06. + +The container health check independently publishes and receives a unique token over authenticated TLS and clears its retained health probe. It verifies listener/auth/ACL/message flow, not just process liveness. + +Inspect status and logs (add the insecure file when applicable): + +```sh +docker compose ps +docker compose logs --tail 50 broker +docker compose exec broker /bin/sh /usr/local/bin/mqtt-healthcheck +docker compose port broker 8883 +``` + +Verify externally from the development computer as well: DNS/IP, firewall reachability, certificate chain and SAN, authentication, and actual MQTT exchange. The server-container verifier does not prove external routing. Record separate local-artifact, server-runtime and physical-hardware results. + +## Stop, update, rollback and persistence + +```sh +docker compose stop broker +docker compose start broker +docker compose restart broker +docker compose down +``` + +The broker saves session/retained data under `data/`. Graceful shutdown has 30 seconds; autosave runs every 30 seconds. Queue sizes, max clients, packet sizes and container resources are bounded. Docker stdout logs rotate at 5 MiB × 3 files. In-memory/broker counters are not per-device durable accounting. + +For a consistent backup, stop this test broker first, then archive `.env`, tracked configuration, `certs/`, `secrets/` and `data/` into a private backup location. Restart after backup. For example, from the deployment directory: + +```sh +docker compose stop broker +sudo tar -czf /root/mqtt5-test-backup.tar.gz .env compose.yaml compose.insecure.yaml mosquitto.conf acl scripts certs secrets data +sudo chmod 600 /root/mqtt5-test-backup.tar.gz +docker compose start broker +``` + +Choose a unique backup name each time. Before an update, preserve the old Compose image reference and a consistent data snapshot. Change the pinned tag/digest intentionally, pull, run --test-config, then recreate only this broker and rerun verification. For rollback, stop the broker, restore the prior configuration/image and matching data snapshot in a dedicated directory, fix UID/GID modes, and start it. Do not assume newer persistence data can be opened by an older image. + +`down` keeps bind-mounted data. Deleting `data/` is an explicit reset and loses sessions/retained messages; do not delete production paths or add automatic destructive cleanup. + +## Certificate renewal + +This package uses a private test CA, not automatic public ACME issuance. Store the signing key privately and track CA/server expiration. Renew before the 90-day server certificate expires. Do not rerun preparation over existing state; it deliberately refuses to rotate credentials/certificates implicitly. + +To renew a server certificate, generate a new key/CSR in a private staging directory, sign with the existing test CA using the SANs in server.ext, and verify chain plus hostname/IP. Keep old cert/key for rollback. Stop only this broker, install the new matching pair in certs/server with owner/group and 0640 permissions, start it, rerun TLS health and external client verification. Removing/replacing the whole directory while the container runs can leave old bind-mount references; stop/recreate when replacing directories. When CA trust changes, distribute the new public CA to clients before switching server trust. + +A public certificate can be integrated later with a deliberate health-check hostname/trust adjustment; public CAs do not issue localhost SANs, so simply replacing this private certificate can break the local TLS health check. No remote DNS, ACME method or renewal timer is assumed. + +## Validation completed during preparation + +- Registry manifest/digest verified for the actual 2.1.2-alpine tag. +- Base and TLS+plaintext Compose configurations: PASS (`config --quiet`). +- Shell/Python syntax: PASS. +- Setup helper with throwaway credentials/certificates in /tmp: PASS, hashed passwords produced without secret output. +- Mosquitto 2.1.2 `--test-config` in network-disabled disposable container: PASS; plugins/configuration accepted, no broker started. +- Server-container runtime TLS/plaintext/auth/ACL/graceful-restart checks: PASS, based on user-provided verifier output on 2026-10-06; external TLS/plaintext authenticated exchanges also passed. +- External client connectivity: PASS, authenticated MQTT 5 device telemetry publish/backend receive on TLS 8884 (CA and hostname verification enabled) and plaintext 1884. Non-retained unique probes only. +- Physical hardware: NOT VERIFIED. diff --git a/docs/DEVICE-MESSAGE-DIAGNOSTICS.md b/docs/DEVICE-MESSAGE-DIAGNOSTICS.md new file mode 100644 index 0000000..936637c --- /dev/null +++ b/docs/DEVICE-MESSAGE-DIAGNOSTICS.md @@ -0,0 +1,45 @@ +# Current functional test mode — 2026-10-07 + +The user requested unrestricted functional testing. This supersedes the earlier diagnosis and restrictions described below. + +- The live remote broker's ACL plugin was removed over SSH and the broker restarted. An original configuration backup is on the server. Password authentication remains; any authenticated account can publish/subscribe to any topic. +- The Docker observer was rebuilt and restarted with `topics.observe_all=true`. It subscribes to `robot-market/test/v1/#`, displays raw payloads and performs no JSON-schema, device identity or timestamp validation. +- Independent socket-client checks passed for MQTT 3.1.1 and MQTT 5, over plaintext 1884 and TLS 8884. Each used the **esp32-lab-001** account for BOTH publication and subscription on **sim-001/telemetry**, sending the user's exact JSON with a literal `{{$isoTimestamp}}`. The broker delivered it and the browser snapshot recorded `message_received`. Arbitrary device/category topic plus plain text also passed in all four combinations. +- Go tests passed with the race detector. These checks exercise the deployed broker and viewer without using the simulator; the Postman application itself still needs a successful connection. + +Live broker logs also show repeated clients attempting **TLS against the plaintext listener** (host port 1884 mapped to container 1883), resulting in protocol errors before MQTT authentication. A client doing this cannot publish anything, regardless of payload or topic permissions. In Postman create a new MQTT request and use `mqtt://109.122.252.129:1884` for plaintext, or `mqtts://109.122.252.129:8884` for TLS with the supplied CA. SSL certificate verification settings control certificate checks, not the choice between plaintext and TLS. + +## Earlier diagnosis (superseded) + + + +## Findings and corrections + +The deployed observer was connected to the remote broker but subscribed only to `sim-001`. The supplied physical-device kit publishes under `esp32-lab-001`, so those packets never reached the observer. Compose now configures both identities using exact ACL-authorized topics: telemetry, status, commands and acks. Message device identity is derived from the topic before contract validation and display. + +Incoming payloads previously had to match the simulator JSON contract before their contents could be displayed. Arbitrary Postman/device payloads produced a generic validation event with omitted contents. The Docker test observer now explicitly enables bounded raw diagnostics. Failed contracts show their received contents and a validation reason, while successful compatibility evidence still requires a valid contract. Default non-Docker configuration keeps raw diagnostics disabled. + +Device accounts can publish their own telemetry/status/acks and read commands. A Postman subscriber must use the test-backend account to receive telemetry. Publishing credentials and topic identity must match. No broker ACL change was needed. + +## Verification + +The observer Docker image was rebuilt and the observer container recreated. The simulator remains stopped. Go tests passed with the race detector. Existing opt-in Go remote tests were skipped in that run; the following independent live check was run separately. + +A standalone Python socket client, independent of the Go simulator and Paho transport, used the ESP32 kit credentials to publish QoS 1 non-retained telemetry. A separate test-backend client subscribed to the same exact topic. Each check verified successful CONNECT, SUBACK, PUBACK, exact payload delivery to the independent subscriber, and matching events in `/api/snapshot`. + +| Transport | Protocol | Valid telemetry | Plain-text diagnostic | +| --- | --- | --- | --- | +| TCP 1884 | MQTT 3.1.1 | PASS | PASS | +| TCP 1884 | MQTT 5 | PASS | PASS | +| TLS 8884 with trusted CA | MQTT 3.1.1 | PASS | PASS | +| TLS 8884 with trusted CA | MQTT 5 | PASS | PASS | + +Valid payloads appeared as `message_received` for `esp32-lab-001`. Plain-text payloads appeared as `validation_failed`, with the exact bounded payload visible. This proves the deployed broker/client/viewer path; it does not claim that physical ESP32 firmware or the Postman UI was exercised. + +## Quick physical-device or Postman test + +Connect to `mqtt://109.122.252.129:1884`, using the ESP32 kit username/password and a distinct client ID. Publish `hello from esp32` to `robot-market/test/v1/esp32-lab-001/telemetry`, QoS 1, retain false. It should appear in the browser at http://127.0.0.1:8080 as a contract error with its text. Then publish the kit JSON to get a valid contract event. + +To receive that publication in Postman, use the local test-backend credential handoff and subscribe to `robot-market/test/v1/esp32-lab-001/telemetry`. A device-account telemetry subscription is not authorized. To receive commands as a device, subscribe to `robot-market/test/v1/esp32-lab-001/commands`. + +Refresh the browser after rebuilding and clear topic/device filters. Subscriptions cover only the configured devices and four categories, not arbitrary broker topics. Payloads are limited to 16 KiB, previews to 4 KiB. The observer cannot determine a publisher's negotiated protocol from a normal forwarded payload; hardware MQTT 5 proof still requires broker session evidence or native-property command replies. diff --git a/docs/PLAN.md b/docs/PLAN.md new file mode 100644 index 0000000..d047a7d --- /dev/null +++ b/docs/PLAN.md @@ -0,0 +1,288 @@ +# MQTT 5 test backend plan + +Planning date: 2026-10-06. Status: Sprints 1–4 implemented and verified, including the local browser viewer; later sprints await authorization. + +## 1. Objective and authorization + +Build a small Go test backend that receives telemetry/status, sends harmless commands, correlates application acknowledgements, and makes every relevant input/output and compatibility result understandable through logs and an optional local browser viewer. + +The user authorized Sprints 1–4, then directed Sprint 2 toward a server Compose package they will deploy themselves, including optional plaintext MQTT alongside TLS. Sprint 1 is complete; Sprint 2 artifacts are validated locally, the user deployed the server broker, and server runtime plus external connection checks passed. Existing projects remain untouched. Sprint 3 MQTT 5 transport/backend/simulator, logs, deduplication, deadlines and graceful shutdown are implemented and verified locally and against the dedicated remote broker. Later sprints still require authorization. + +Project root: `/home/ryan/Media/Code/Code/robot-market/test-mqtt-5`. + +Follow applicable AGENTS.md instructions and `/home/ryan/.codex/RTK.md`; prefix shell commands with `rtk`. The existing server repository's build/test restrictions apply when working there; do not build or test that reference project as part of this work. + +## 2. Findings and evidence + +Reference root: `../robot-market-vending-backend/server_backend` (relative to the new project root). + +| Evidence | Finding | Limit | +| --- | --- | --- | +| `go-back/go.mod` | Go 1.25.0, `github.com/eclipse/paho.mqtt.golang v1.5.0` | This is the MQTT 3 client, not the MQTT 5 implementation | +| `go-back/internal/mqtt/client.go` | TLS >=1.2, optional CA/client certificates, 30-second keepalive, auto reconnect, role-based client IDs | Does not establish the remote broker's active configuration | +| `go-back/internal/config/config.go` | Default QoS 1 and CleanSession=false | Defaults are not evidence of deployed values | +| `go-back/cmd/ingest/main.go` and `internal/ingest/subscriber.go` | Startup subscription registration; connection callback primarily logs | No explicit subscription rebuilding on lost broker session in this inspected path | +| `docs/contracts/handoffs/server-mqtt-correlation-ack-handoff-fa.md` | JSON `requestId` or `message_id`/`sync_job_id`; explicit `ok:true`; exact response-topic matching | These are application fields, not native MQTT 5 properties | +| `docs/phases/phase2/12-device-apt-repo-foundation.md` | Bookworm ARM64 packages for `app-core`, `io`, `mqtt-bridge`, `storage`, `payment` | Evidence of a Linux device service; its source/library/version was not available | +| `docs/architecture/runtime-and-deploy.md` | Mosquitto deployment references external `vending/docker` | Actual deployment artifacts, OS, CPU, listeners and certificates unavailable | +| Repository instructions | FN identifies the board; SN identifies the machine | This project must not allocate or mutate those identities | + +Existing topics include `devices/...`, `device/{plate}/...`, and `vending/{serial}/...`. Some synchronization responses use `device/{plate}/sync/resp/{message_id}`. The new namespace intentionally isolates test traffic. + +No ESP32 firmware, PlatformIO project, ESP-IDF configuration, or device MQTT implementation was found in the supplied parent project tree during discovery. “Device backend” appears to include a Linux service; the relationship to ESP32 remains unknown. Do not infer direct ESP32 MQTT connectivity or firmware capabilities from these documents. + +Do not read/copy production secrets. No existing code changes are planned. + +## 3. Architecture + +```mermaid +flowchart LR + Browser[Local browser] -->|Loopback HTTP + SSE| Backend[Go test backend] + Backend -->|Outbound MQTT 5 / TLS| Broker[Isolated Mosquitto] + Simulator[Local Go simulator] -->|Outbound MQTT 5 / TLS| Broker + Device[Test hardware or device service] -->|Outbound TLS / verified protocol| Broker +``` + +The diagram arrows show connection initiation. MQTT messages travel in both directions over those connections. No public inbound access to the development computer is required. + +The current broker deliverable is a server deployment package under deploy/. The user will deploy a separate broker instance using available TLS and optional plaintext test ports; existing production listeners stay untouched. No permanent local broker is required. If local isolation is later needed, bind host ports to loopback. + +### Go structure and runtime + +Planned entry points: `cmd/test-mqtt5` and `cmd/device-sim`. The backend has `observe` and `command` CLI modes; `command` supports `ping` and `get_version`. The viewer belongs to long-running observation mode. An observer can display command/ack traffic from a separate command-mode client but must not claim that traffic as its own measured pending-request latency. + +Use focused `internal/config`, `internal/contracts`, `internal/mqtt`, `internal/backend`, `internal/simulator`, and `internal/events` packages; add `internal/web` only with the viewer sprint. Package names may be simplified if responsibilities remain clear. Use standard `flag`, `net/http`, `embed`, `encoding/json`, and `log/slog` rather than frameworks. + +Construct dependencies explicitly. Use contexts and operation deadlines, contextual wrapped errors, fixed worker counts, bounded channels, and clear mutex/event-loop ownership. Interfaces only support actual testing or transport boundaries. Callbacks copy necessary data and enqueue quickly; they do not do HTTP, disk work, slow logging, or application execution. Connection hooks signal a supervised worker rather than starting unbounded goroutines. + +On SIGINT/SIGTERM: stop admitting commands, stop HTTP/SSE, resolve pending requests as interrupted/unknown, publish graceful offline status where applicable within a bounded deadline, disconnect MQTT, cancel and join workers. Return a meaningful exit status. + +### Resource defaults + +Initial lab defaults: one simulator device, telemetry every 5 seconds, 16 KiB maximum application payload, 128 pending commands, 256 queued inbound events, 1,000 displayed events, 4 KiB payload preview, eight SSE viewers, and a 64-event queue per viewer. Make necessary limits configurable and validate them; do not make a flag for every setting. + +On telemetry/event overflow: count and expose a gap/drop. On command-processing overload: never execute work that was not admitted; emit a rejection when feasible, otherwise allow a visible application timeout. MQTT receipt/transport ACK is not proof of admission. Event-stream loss must not affect command tracking. Clamp negotiated MQTT receive/packet limits to supported values and test the actual pinned client's behavior. + +## 4. Dependencies and version policy + +These are proposal pins established during discovery, not a tested lockfile. Before implementation, confirm tag existence, Go requirements, security advisories, and image platform/digest availability. Report any required version change instead of silently changing the approved design. + +| Dependency | Proposed version | Source | +| --- | --- | --- | +| Go toolchain | 1.27.1 | https://go.dev/dl/ | +| `github.com/eclipse/paho.golang` including `autopaho` | v0.23.0 (requires Go >=1.24) | https://github.com/eclipse-paho/paho.golang/releases/tag/v0.23.0 | +| `github.com/knadh/koanf/v2` | v2.3.7 (requires Go >=1.23) | https://github.com/knadh/koanf/releases/tag/v2.3.7 | +| `github.com/knadh/koanf/parsers/toml/v2` | v2.2.2 | https://pkg.go.dev/github.com/knadh/koanf/parsers/toml/v2@v2.2.2 | +| `github.com/knadh/koanf/providers/file` | v1.2.1 | https://pkg.go.dev/github.com/knadh/koanf/providers/file@v1.2.1 | +| `github.com/knadh/koanf/providers/env` | v1.1.0 | https://pkg.go.dev/github.com/knadh/koanf/providers/env@v1.1.0 | +| Mosquitto | Verified `eclipse-mosquitto:2.1.2-alpine@sha256:38c0da4f2ef84284d47b3b3eeea1cb3bdeabe81ee10caf0cd5c5ff61ee3ea408` | https://mosquitto.org/download/ | +| ESP32 SDK/client | Not selected until device source/board is known | https://github.com/espressif/esp-mqtt | + +Paho is released but still pre-1.0: API stability must not be assumed. Commit exact module versions and go.sum when implementation begins. Do not use `paho.mqtt.golang` in the new MQTT 5 implementation. No Node/npm dependency is needed for the viewer. + +Additional official references: + +- Autopaho controls and lifecycle: https://raw.githubusercontent.com/eclipse-paho/paho.golang/v0.23.0/autopaho/auto.go +- koanf loading/providers: https://github.com/knadh/koanf +- MQTT semantics: https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html +- Mosquitto settings: https://mosquitto.org/man/mosquitto-conf-5.html +- Mosquitto 2.1 migration notes: https://mosquitto.org/blog/2026/01/version-2-1-0-released/ +- ESP-MQTT MQTT 5 example: https://components.espressif.com/components/espressif/mqtt/versions/1.0.0/examples/mqtt5?language=en + +ESP-MQTT can support MQTT 5, but build configuration and runtime protocol selection must be checked. Do not choose or migrate an ESP32 framework without inspecting the actual device project. + +## 5. Configuration + +Precedence: defaults < TOML file < environment < explicitly supplied CLI overrides. Use typed structs and load once at startup. A supplied missing/malformed file is an error. Only flags actually visited override merged values. + +Prefix: `TEST_MQTT5_`. Strip that prefix, lowercase, and map `__` to nested `.` while preserving single underscores: `TEST_MQTT5_MQTT__CLIENT_ID` -> `mqtt.client_id`. Reject unknown keys under this prefix. Treat empty secrets deliberately; never silently fall back to a different credential. + +Sections cover broker URL, username/password or password-file, TLS CA/client certificate paths, client ID, topic namespace/device ID, QoS, initial Clean Start, Session Expiry, keepalive/backoff, operation/shutdown deadlines, logging, event limits, simulator behavior, and optional web viewer. Mutually exclusive secret sources receive actionable validation errors. + +Use duration strings such as `5s` and `5m`, explicit decode hooks/conversion, and overflow/range checks before converting MQTT seconds to uint32. Reject bare numeric durations, invalid QoS, unsupported URL schemes, partial certificate pairs, wildcard/path injection in IDs, and deadlines inconsistent with deduplication retention. Unknown TOML keys should fail startup. + +Flags: `--config`, `--device-id`, `--log-level`, and optionally `--web` are sufficient. Command arguments select the harmless operation and request ID. Never provide secrets through flags or log complete configuration objects. + +TLS remains the default for broker connections. Per user request, the server package also offers an explicit Compose override publishing authenticated plaintext MQTT. Sprint 3 adds an explicit test-only mqtt.allow_plaintext setting (default false) to permit a mqtt:// URL when selected; implemented Sprint 3 validation supports plaintext only with the explicit opt-in. TLS connections always verify hostnames/SANs, using system roots and optionally a test CA. Client certificate/key is optional. Never set InsecureSkipVerify. Ignore local TOML, generated certificates, private keys, broker data, and credential files in Git. Examples contain placeholders only. + +## 6. Topics and payloads + +### 6.1 ESP32 connection configuration (supplied by user) + +The user supplied this C connection configuration and clarified that it is not the application payload contract: + +```c +typedef struct +{ + const char* wifi_ssid; + const char* wifi_password; + const char* mqtt_server; + int mqtt_port; + const char* mqtt_user; + const char* mqtt_pass; + const char* subscribe_topic; + const char* publish_topic; + const char* ca_cert; +} Esp32MqttConfig; +``` + +This struct does not constrain the Go application's message structs or establish payload encoding. Go backend/simulator implementation is not blocked on a C serializer. The topic/payload examples in section 6.2 remain the proposed test contract; actual device payload compatibility is verified when the device code is available. + +| C setting | Meaning for this environment | Go/backend impact | +| --- | --- | --- | +| wifi_ssid / wifi_password | ESP32 network connection | Device-only; never copy Wi-Fi credentials into Go config or logs | +| mqtt_server / mqtt_port | Address of the isolated test broker and TLS listener | Go uses its own TLS URL for the same reachable broker; confirm whether C expects hostname or URI | +| mqtt_user / mqtt_pass | Dedicated device credentials | Go uses separate backend credentials; ACLs reflect each role | +| subscribe_topic | Device's test command subscription | Set to `robot-market/test/v1/{device_id}/commands` for the proposed contract | +| publish_topic | A configured device publication topic | One string alone does not establish support for telemetry, status and ACK topics; inspect publish call sites before choosing a device adaptation | +| ca_cert | Device trust material | Inspect whether this is PEM text or a path; Go uses system roots/its own CA path. Never copy C pointers as configuration values | + +The existing fields can still describe MQTT 5 broker connectivity. No struct change is required solely to move application payloads to MQTT 5. This struct has no explicit protocol-version field, so it cannot establish that the device uses MQTT 3.1.1 or MQTT 5: inspect the actual library/version, build options and client initialization. Port and certificate settings alone also do not prove TLS is enabled or hostname verification is enforced. + +If the library supports MQTT 5, select it in client initialization/build configuration. Only add a protocol-version field to Esp32MqttConfig if runtime selection between 3.1.1 and 5 is actually needed. Do not prescribe enum values or exact library calls before identifying the C library. MQTT 5 Session Expiry, Clean Start, Will, Response Topic, Correlation Data and Message Expiry are configured through that library's supported APIs and need not all become fields in this small struct. Preserve JSON/application correlation for any 3.1.1 compatibility tests; never label it native MQTT 5 metadata. Reference: [OASIS MQTT 5 specification](https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html). + +For the proposed four-topic test contract, inspect whether existing publish calls accept a topic per message. If yes, derive telemetry/status/ACK topics from a test namespace and device ID. If publication is fixed to publish_topic, propose the smallest device-only extension (for example namespace/device ID or explicit additional topic settings) before implementing it. Do not silently change the Go contract to one multiplexed topic, or subscribe to production topics. No C changes are authorized by documenting these alternatives. + +Verification for device integration: isolated broker host/port, CA representation, TLS enablement and hostname verification, device clock, separate credential/ACL behavior, exact subscription/publication topics, library protocol selection and actual message/property exchange. For a 3.1.1 endpoint, ordinary payload interoperability with the Go MQTT 5 backend is testable through Mosquitto, but native property handling remains unsupported unless that endpoint is upgraded. The Go backend and simulator continue to use Paho MQTT 5 as planned. + +### 6.2 Proposed test topics and payloads + +Namespace: `robot-market/test/v1/{device_id}`. Test IDs use a restricted safe alphabet, such as `sim-001` or `esp32-lab-001`. Optional FN/SN metadata is informational only. + +| Suffix | Direction | Default QoS | Retain | +| --- | --- | --- | --- | +| telemetry | Device -> backend | 1 | false | +| status | Device -> backend | 1 | true | +| commands | Backend -> device | 1 | false | +| acks | Device -> backend | 1 | false | + +The proposed test messages have `schema_version:1` and `device_id`; match identity against the topic. Esp32MqttConfig does not define these payloads. Real-device payload support must be checked separately. Reject oversized input before decoding. Validate JSON objects, command allowlists, bounded strings, required identifiers, RFC3339 UTC timestamps, supported stages, and reasonable deadline ranges. MQTT payload-format/content-type properties can label JSON, but payload validation remains necessary. + +Telemetry example: + +```json +{"schema_version":1,"device_id":"sim-001","message_id":"evt-001","boot_id":"boot-001","sequence":1,"timestamp":"2026-10-06T10:00:00Z","readings":{"uptime_s":12}} +``` + +Online status example: + +```json +{"schema_version":1,"device_id":"sim-001","boot_id":"boot-001","state":"online","timestamp":"2026-10-06T10:00:00Z","implementation":"go-simulator","reported_protocol":"5"} +``` + +Reported protocol is self-reported metadata, not independent proof. An offline Will carries `state:"offline"`, boot ID and `will_configured_at`; it must not label a preconstructed timestamp as the disconnect time. The observer adds its own reception time. + +Command and completion examples: + +```json +{"schema_version":1,"device_id":"sim-001","request_id":"req-001","issued_at":"2026-10-06T10:00:00Z","expires_at":"2026-10-06T10:00:15Z","command":"ping"} +``` + +```json +{"schema_version":1,"device_id":"sim-001","request_id":"req-001","timestamp":"2026-10-06T10:00:01Z","stage":"completed","ok":true,"result":{"pong":true}} +``` + +Acceptance is a separate ACK with `stage:"accepted"`. Terminal stages are `completed`, `rejected`, and `failed`; rejection/failure contains a bounded error code/message. Out-of-order or repeated ACKs must not regress terminal state. A completion received before acceptance still resolves the request correctly. + +MQTT 5 command properties: Response Topic equals the exact device `acks` topic; Correlation Data is bounded opaque bytes and is echoed unchanged. Compare native bytes and JSON request ID when native correlation is enabled. Reject response topics outside the exact allowed topic, including wildcards. A documented JSON-only compatibility mode is separately selected and cannot count as a native-property test. + +These contracts are test-only and are not derived from Esp32MqttConfig. Existing `requestId`, `message_id`, `/resp` and sync behavior is not silently reinterpreted; any real-device payload adaptation requires an explicit mapping, and no production namespace bridge is included. + +## 7. Reliability and session policy + +- Client IDs include lab/role/instance, e.g. `rm5-lab-observer-01`, `rm5-lab-command-01`, `rm5-lab-device-sim-001`. Concurrent processes need distinct configured instance IDs. Never reuse a production ID. +- Initial Clean Start=true and Session Expiry=300 seconds. Within a running process, reconnect with Clean Start=false. Client-process restart deliberately starts fresh because client protocol/pending/dedup state is initially in memory. Cross-process durable client recovery is outside this baseline. +- On every CONNACK inspect Session Present. When false, restore required subscriptions and check SUBACK reasons before readiness. Callback handlers must already exist when a resumed session can deliver queued messages. If session exists, retain its subscriptions rather than blindly replaying them. +- Reconnect backoff uses jitter and a bounded 1–30-second range; context cancellation always stops it. Initial connection has a deadline. Denials are observable and do not produce tight retry loops. +- Publish retained online status only after command subscription readiness. Configure retained QoS 1 offline Will with explicit zero Will Delay. Graceful shutdown attempts offline publication before normal disconnect. Network failure detection still depends on keepalive; absence/staleness is unknown, not proof of current online state. +- Commands default to a 15-second application lifetime and MQTT Message Expiry consistent with remaining lifetime. Expired messages are never newly published or executed. A delayed delivery must be checked against expires_at even if broker expiry was set. +- No unbounded offline application publish queue. If disconnected, reject new CLI commands visibly. Already submitted QoS traffic may still complete or be retransmitted after the caller times out. +- Distinguish publish attempt, successful transport acknowledgement, application acceptance, and completion. Inspect MQTT reason codes; a returned publish result is not automatically success. +- Register correlation before publishing and after ACK subscription readiness. Local timeout means unknown delivery/execution outcome; late ACKs are visible and classified late. +- Intentional retry reuses request ID, operation, and original deadline. The simulator deduplicates by device/request ID, remembers in-progress and final results, and rejects same-ID/different-content conflicts. Do not rely on the MQTT DUP bit as the application deduplication key. +- Dedup cache is bounded (initially 1,024 requests, ten-minute retention; command lifetime capped below retention). Do not evict active/unexpired protected entries to accept more work: reject overload. Cache loss on restart is documented; no exactly-once side-effect guarantee is claimed. +- Message Expiry limits broker delivery; it cannot cancel already delivered work. The device checks application deadlines before work and applies its own cancellation policy. Device time must be synchronized before TLS/deadline hardware tests. + +## 8. Logs and input/output inspection + +Use one sanitized event model for logs and browser display. Event fields include event ID, observed UTC timestamp, direction (`in`, `out`, `internal`), local client/device identity, topic, QoS, retain/DUP flags when available, payload byte length, bounded preview, MQTT properties, request ID, outcome/reason, and measured latency where locally attributable. + +Differentiate events such as `publish_attempt`, `transport_ack`, `message_received`, `validation_failed`, `command_accepted`, `command_completed`, `command_timeout`, `late_ack`, `duplicate_request`, `connection_up`, `connection_down`, and `event_gap`. Never display a publish attempt as proven device receipt. + +Default safe previews show the explicitly allowed harmless test fields. Redact secret/token/password fields before logging; do not dump arbitrary invalid JSON or binary payloads. Invalid payload events show metadata and validation errors without echoing raw input. Diagnostic preview opt-in remains bounded and sanitized. Never log credentials, TLS private material, config dumps, or URL userinfo. + +Provide text logs for humans and JSON logs for scripts. Basic counters: attempts/connections/reconnects, publish attempts/transport successes/failures, received messages, validation failures, ACK stages, own-request acceptance/completion latency, timeouts, duplicate requests/ACKs, and dropped inspection events. Periodic and shutdown summaries are enough; no metrics server is required. + +Usage labels are explicit: application payload bytes exclude MQTT/TCP/TLS overhead; subscriber counts describe its deliveries, not total broker traffic; $SYS generally measures the broker; in-memory counters are not durable accounting. Telemetry history means only the bounded current-process event buffer unless separately approved. + +## 9. Optional browser viewer (Sprint 4 complete) + +Use Go net/http + embedded HTML/CSS/vanilla JavaScript. Default disabled; `--web` enables `127.0.0.1:8080`. No CDN, frontend build tool, or separate service. Changing to a public bind is outside this baseline. + +Read-only pages show connection/readiness, counters, an event timeline with direction/topic/device/request filters, payload/property details, command lifecycle, and a compatibility table. Show empty, disconnected, truncated, and event-gap states. Pause freezes display, not backend processing. + +Use GET endpoints for initial sanitized event snapshot and Server-Sent Events for live updates. Event IDs allow replay from the bounded ring on reconnect; if history is gone, emit an explicit gap/reset. Slow browser clients use bounded queues and are disconnected or notified of gaps without blocking MQTT. Clean up subscriptions on close. No durable browser history or command-publishing HTTP endpoint. + +Render text via textContent, never payload-supplied HTML. Add a restrictive CSP, same-origin behavior, expected Host/Origin checks, no permissive CORS, and no remote script loading. This is a local developer tool, not a public authenticated dashboard. SSE reconnects must not duplicate event counters or multiply handlers. + +## 10. Compatibility reporting + +Track each feature per endpoint with `not_tested`, `passed`, `failed`, `unsupported`, or `blocked`, plus evidence, timestamp, endpoint identity, client version when known, and environment (`local_simulator`, `remote_simulator`, `physical_device`). A protocol claim alone cannot mark a feature passed. + +| Feature | Go simulator | Existing Linux device service / ESP32 | +| --- | --- | --- | +| TLS, topic/payload contract, QoS 1 | Planned automated proof | Unknown until actual client/source and configuration inspected | +| JSON command/ACK correlation | Planned | Requires test contract support; old payloads are not automatically compatible | +| Native Response Topic / Correlation Data | Planned end-to-end echo verification | Requires MQTT 5 client and property handling | +| Session expiry and subscription restoration | Planned controlled outage scenarios | Requires configured client behavior and observed results | +| Broker Message Expiry | Planned queued-delivery scenario | Broker-side behavior plus device deadline enforcement tested separately | +| Will/retained online state | Planned crash and graceful-stop scenarios | Requires test configuration and freshness interpretation | +| Duplicate request handling | Planned deterministic repeat plus optional wire retransmission | Requires actual application idempotency behavior | + +A backend cannot infer another subscriber's MQTT version merely from a delivered message. Use device client source/configuration plus test-broker connection evidence and successful native-property behavior. Cross-version delivery alone proves neither MQTT 5 support nor property preservation. + +Initially, live events and scenario results are separate concepts: merely seeing traffic does not auto-pass every compatibility row. Scenario tests emit explicit verdicts. The browser can show supported live evidence and loaded/available current-process results; untested rows remain not_tested. No upload/import framework or persistent report database is planned. + +## 11. Broker deployment and operations + +Prefer Docker Compose on a supported server. If Docker is unavailable, propose an exact native package/systemd alternative before implementation. Confirm OS/CPU, permissions, existing port use, DNS/SANs, certificate provisioning, and firewall first. + +Use dedicated test data/configuration and a verified image digest. Enable authenticated TLS and disable anonymous access. The base Compose file publishes only TLS (default server port 8884); the user-requested insecure override additionally publishes plaintext MQTT (default 1884), with the same credentials/ACLs and explicit firewall scoping. Plaintext is not a certificate-verification bypass. Mosquitto 2.1 has new password/ACL plugin guidance; validate the exact pinned release syntax and plugin paths in the container rather than copying a 2.0 recipe. + +Backend can publish only test commands for allowed devices and read their telemetry/status/ACKs. Each device can read only its commands and publish only its own telemetry/status/ACKs. A health-check account can only access a separate health topic. Deny production topics, cross-device writes, and unnecessary $SYS access. MQTT authentication does not remove the need for topic/payload identity validation. + +Persist broker sessions/retained messages for restart tests; configure queue, packet and client limits. Restrict config/certificate/password-file ownership and modes to the actual image UID/GID. Mount secrets read-only; avoid exposing them in command arguments or Compose output. Bound container logs, document persistence checkpoint/crash-loss limits, and avoid promising crash durability from a graceful-restart test. + +Health check performs authenticated TLS subscribe/publish/receive with a unique ID and timeout on the health topic. It should detect broken listener/auth/ACL behavior, not merely a live process. Clients must still perform independent remote hostname verification. + +Firewall allows only the selected TLS port for intended clients where feasible. Public-CA certificate issuance/renewal or private test CA is chosen after DNS/network details are known. Renewal copies readable cert material safely, reloads/restarts the isolated broker as required, and verifies the served certificate. Do not weaken hostname checks for IP-only access; use a matching IP SAN or proper DNS. + +Runbook includes start, stop, logs, health, config validation, backup, update, and rollback. Back up configuration and a consistent broker data snapshot before update. Rollback restores the prior image and compatible data snapshot; newer persistence data may not be backward-compatible. No production broker changes are authorized. + +## 12. Verification and implementation sequence + +Sequence: config/contracts -> server Compose/setup package and operator deployment -> backend/simulator using the provided test-broker endpoint -> browser viewer -> reliability/compatibility scenarios -> remote operational review -> conditional physical-device integration. Detailed sprints are separate files under `docs/sprints/`, linked from [SPRINTS.md](SPRINTS.md). After each sprint, execute its automated and manual checks, review changes, fix failures and rerun affected verification. Report every acceptance criterion with evidence as PASS/FAIL/BLOCKED. Required checks that cannot run block full completion; never count a skip as a pass. + +Unit checks cover precedence/typed decoding, validation, correlation including wrong device/topic/properties, terminal-state ordering, timeout and late ACKs, deadline checks, duplicate/conflicting IDs, limits, redaction, cancellation, and shutdown. Use gofmt, go vet, focused tests and race detection in the new project only. + +Real-broker scenarios cover initial exchange, bad credentials, forbidden topics, invalid CA/hostname, broker restart, network interruption, session resume versus loss/expiry, subscription recovery, queued command expiry, missing/delayed ACK, Will, graceful shutdown, and bounded resource behavior. Deterministic repeated application messages test idempotency; actual QoS 1 retransmission needs controlled transport interruption and may not reproduce on every run. Record that distinction. + +Browser verification covers live traffic, escaped hostile strings, redaction/truncation, filters, reconnection/replay/gap, slow clients, and shutdown. Compare rendered events to logs and avoid double-counting observer traffic as sender-owned latency. + +Every report separates verified locally, verified against remote broker, verified on physical hardware, and not verified. Sprint 1 local configuration, payload validation, safe previews and offline CLI behavior are verified. Sprint 2 Compose/setup/certificate/configuration checks are verified locally. Server-container MQTT security and graceful persistence checks passed in user-provided output. External MQTT 5 authenticated publish/receive passed from this development computer over verified TLS and plaintext on 2026-10-06. Sprint 3 unit/race checks, real CLI log/signal checks, TLS/plaintext command/telemetry exchanges, native correlation, duplicate/conflict handling, timeout/late ACK/unknown outcomes and reconnect with retained/lost sessions passed. Physical hardware remains unverified. + +## 13. Dependencies, risks and open inputs + +| Input/risk | Effect | Response | +| --- | --- | --- | +| ESP32 MQTT initialization/library unknown | Blocks exact device-side MQTT 5 selection and TLS/topic verification, not Go implementation | Inspect the C client setup before the device sprint; use supplied Esp32MqttConfig as connectivity reference | +| Missing device source/board | Blocks real-device client/version/contract decisions | Continue with simulator; obtain paths and board details before hardware sprint | +| Missing remote OS/CPU/access/DNS | Blocks exact remote deployment/cert choices | Validate local artifacts first; collect details without requesting secrets in chat | +| Local container tooling unavailable | Blocks real-broker tests | Report prerequisite; propose native isolated broker if appropriate | +| Client-ID collision | Disconnects another lab client | Configured role/instance IDs; separate observer and command instances | +| Device clock drift | TLS or deadline failures | Synchronize clock and report clock-dependent tests honestly | +| In-memory state loss | Lost history/dedup/pending requests on restart | Explicit limits; fresh client session on process restart; harmless operations only | +| Browser overload or hostile payload | Inspection instability or injection | Bounded fan-out and text-only sanitized rendering | +| Dependency/image availability | Proposed pins may need revision | Recheck official metadata and report material changes before adopting them | + +User inputs needed before their dependent work: device service/firmware paths and available boards; remote OS/CPU/tooling/free port/access and certificate situation; whether usage beyond local counters and bounded recent history is required. None blocks writing these documents or reviewing the local-first sprints. diff --git a/docs/RUNNING.md b/docs/RUNNING.md new file mode 100644 index 0000000..208a671 --- /dev/null +++ b/docs/RUNNING.md @@ -0,0 +1,193 @@ +# Run the MQTT 5 backend and simulator + +Sprints 3–4 use the dedicated deployed broker. All commands below are standard Linux commands, run on your development computer. The existing production project is not involved. + +## Run with Docker Compose + +`Dockerfile` builds both Go programs into one image. The root `compose.yaml` starts the observer and browser viewer; its optional `simulator` profile starts the Go test device. This connects outbound to the already deployed remote broker. The broker itself remains a separate Compose deployment under `deploy/`. + +The Compose setup uses Linux host networking so the Go viewer can keep its enforced `127.0.0.1:8080` bind. It does not publish the viewer on an external interface. Run this on a Linux Docker Engine host. The container runs as your host UID/GID so it can read the private, read-only credential bind mounts without changing their permissions. + +Copy `.env.docker.example` to `.env` and set the paths for the CA and private JSON handoffs on this computer. `.env` is ignored by Git; it contains paths only, not credential contents. For the current setup, the example paths point to `$HOME/mqtt-test-credentials`. Set `LOCAL_UID` and `LOCAL_GID` to `id -u` and `id -g` if they differ from the example. + +Start the observer and wait for its `subscriptions_ready` log: + +```bash +docker compose up --build -d observer +docker compose logs -f observer +``` + +Then open . In another terminal, start the simulator profile: + +```bash +docker compose --profile simulator up -d simulator +``` + +Follow both event logs with `docker compose logs -f observer simulator`. Stop the simulator and observer with `docker compose --profile simulator down`. For a harmless ping, run the one-shot command container: + +```bash +docker compose run --rm --no-deps \ + -e TEST_MQTT5_WEB__ENABLED=false \ + -e TEST_MQTT5_TOPICS__OBSERVE_DEVICES= \ + -e TEST_MQTT5_TOPICS__OBSERVE_ALL=false \ + -e TEST_MQTT5_RECEIPTS__ENABLED=false observer \ + --broker tls://109.122.252.129:8884 --ca /run/test-mqtt/ca.crt \ + --credentials /run/test-mqtt/backend.json \ + --client-id rm5-docker-command-01 command ping +``` + +The command container inherits the CA and backend credential mounts from the observer service. The `-e` override disables the viewer for this short-lived `command` mode. + +## Build + +```bash +cd /home/ryan/Media/Code/Code/robot-market/test-mqtt-5 +go build -buildvcs=false -o bin/test-mqtt5 ./cmd/test-mqtt5 +go build -buildvcs=false -o bin/device-sim ./cmd/device-sim +``` + +Client credential handoffs are private JSON files copied from the server. `--credentials` reads only username/password; it never logs them. Select the broker URL and CA explicitly. Flags precede subcommands. Every running process must use a distinct client ID: sharing one disconnects the previous client. Simulator and command modes have separate defaults; command mode generates a new client ID unless configuration overrides it. + +## TLS: open three terminals + +Run the simulator in the first terminal: + +```bash +./bin/device-sim \ + --broker tls://109.122.252.129:8884 \ + --ca "$HOME/mqtt-test-credentials/ca.crt" \ + --credentials "$HOME/mqtt-test-credentials/sim-001.json" \ + --client-id rm5-lab-simulator-01 +``` + +Run the observer in the second: + +```bash +./bin/test-mqtt5 \ + --broker tls://109.122.252.129:8884 \ + --ca "$HOME/mqtt-test-credentials/ca.crt" \ + --credentials "$HOME/mqtt-test-credentials/test-backend.json" \ + --client-id rm5-lab-observer-01 observe +``` + +Send harmless commands in the third: + +```bash +./bin/test-mqtt5 \ + --broker tls://109.122.252.129:8884 \ + --ca "$HOME/mqtt-test-credentials/ca.crt" \ + --credentials "$HOME/mqtt-test-credentials/test-backend.json" command ping + +./bin/test-mqtt5 \ + --broker tls://109.122.252.129:8884 \ + --ca "$HOME/mqtt-test-credentials/ca.crt" \ + --credentials "$HOME/mqtt-test-credentials/test-backend.json" command get_version +``` + +You should see `connection_up`, `subscriptions_ready`, simulator online status and periodic telemetry. Follow a command's request ID through `publish_attempt`, `transport_ack`, `command_accepted`, and `command_completed`. A completed ping contains `pong:true`; version returns `test-sim-1.0.0`. Transport ACK confirms broker acceptance, not execution. Fast application ACKs can be logged before the transport publish call returns. + +The observer sees other clients' command/ACK traffic. It cannot measure their own-request latency: it labels ACKs without an active own request as `late_ack`, with outcome `no active own request; latency not measured`. Command mode measures its own latency and prints final counters. + +Press Ctrl+C to stop the observer or simulator. The simulator attempts retained offline status before disconnecting. Its configured Will also reports offline on an ungraceful connection loss, with `will_configured_at` rather than an invented disconnect timestamp. A Will is an availability indication, not proof of why a device stopped. + +## Browser viewer + +The viewer is optional, starts only with the long-running `observe` process, and listens on `127.0.0.1:8080`. Create an ignored local config file: + +```bash +cat > viewer.local.toml <<'EOF' +[web] +enabled = true +EOF +``` + +Add `--config viewer.local.toml` to the observer command in the TLS section, before `observe`, then open . Keep the observer running. Start the simulator in its own terminal to generate telemetry, and use a third terminal for `command ping` or `command get_version`. The page refreshes event counters and streams sanitized events live. Its device JSON-contract row means the payload matched the Go contract; it does not identify hardware or infer the device MQTT version. It offers direction, topic, device and request filters, pause/resume, and clear. The viewer has no publish endpoint; commands remain CLI-only. + +The timeline keeps at most 1,000 displayed events; the server snapshot is limited by configured event history and SSE replay to 500 events. A gap notice means the page must resynchronize after events were evicted or its bounded queue overflowed. Compatibility evidence distinguishes the Go observer's MQTT 5 session from device telemetry and native MQTT 5 request/correlation support; the latter is not inferred from a payload. + +## Plaintext testing + +Use the same commands with `--broker mqtt://109.122.252.129:1884 --allow-plaintext` instead of the TLS URL/CA flags. The opt-in is required in each process, and credentials/ACLs still apply. Plaintext carries credentials and payloads without encryption. TLS certificate verification is never disabled. + +For example: + +```bash +./bin/device-sim --broker mqtt://109.122.252.129:1884 --allow-plaintext \ + --credentials "$HOME/mqtt-test-credentials/sim-001.json" + +./bin/test-mqtt5 --broker mqtt://109.122.252.129:1884 --allow-plaintext \ + --credentials "$HOME/mqtt-test-credentials/test-backend.json" command ping +``` + +Run only one simulator for sim-001 at a time; the dedicated device identity and topics represent one simulated device. + +## JSON logs and configuration + +Create an ignored `logging.local.toml` to inspect structured JSON events: + +```toml +[logging] +format = "json" +level = "info" +``` + +Add `--config logging.local.toml` before the subcommand. Native correlation bytes appear base64-encoded in JSON. Payload previews contain only validated contract fields and are bounded; malformed inputs are omitted and free-form ACK error messages are redacted. MQTT user properties and arbitrary response topics are not logged. Log levels warn/error suppress normal info events; use info for input/output inspection. + +Defaults < TOML < environment < explicitly supplied flags. The private handoff supplied with `--credentials` acts as an explicit username/password override. TOML/client-ID settings can override role defaults; use `--client-id` to prevent collisions when sharing a full config file. `check-config` remains offline even with a credential handoff. + +Runtime requires `mqtt.clean_start=true` for the initial process connection. Default session expiry is 300 seconds; reconnects inside that process use Clean Start=false. When the broker session is missing, subscriptions are rebuilt and SUBACK reason codes checked before readiness. Reconnect backoff uses bounded jitter (default 1–30 seconds). Initial readiness, publish and shutdown operations are bounded. + +Defaults are 256 inbound messages, 1,000 retained inspection events, 128 pending commands, 1,024 simulator dedup entries retained for 10 minutes, 16 KiB payloads and 4 KiB previews. The simulator processes commands in one worker. Full dedup capacity rejects new requests rather than evicting an in-progress entry. Incoming overflow counts `event_gap`; a MQTT receipt ACK does not prove simulator admission. Dedup retention/cache and client protocol state are memory-only; restarting a client does not promise durable deduplication or recovery. + +Commands are non-retained QoS 1, have a default 15-second deadline plus MQTT Message Expiry, and use exact native Response Topic/Correlation Data. Backend native correlation is the request ID's bytes; the simulator supports any nonempty correlation value up to 64 bytes and echoes it unchanged. It never executes beyond the application deadline. Completed duplicates replay a cached ACK; in-progress duplicates replay acceptance. Conflicting command/correlation reuse is rejected. Timeouts and connection loss report execution unknown, without an application outbox or automatic command retry. MQTT QoS 1 itself may retransmit an in-flight packet during reconnect, which is why deduplication is required. + +## Verification + +Offline checks: + +```bash +gofmt -l cmd internal +go vet ./... +go test ./... +go test -race ./... +go mod verify +``` + +Real-broker tests are explicitly opt-in and use only the dedicated test accounts/topics. They run simulators, publish non-retained test commands/telemetry, update sim-001 retained status, and briefly drop their own local test connection to verify reconnection. Do not run them while another sim-001 simulator is active. They do not restart or reconfigure the broker. + +```bash +MQTT5_INTEGRATION_CREDENTIAL_DIR="$HOME/mqtt-test-credentials" \ + go test -race -v -count=1 -timeout=90s ./internal/integration +``` + +Without that variable, integration tests skip network access; those skips are not evidence of remote success. The recorded Sprint 3 remote run used the copied credential files against 109.122.252.129 and passed on both transports. Physical ESP32 interoperability and the larger reliability matrix remain later sprints. Browser viewer checks are recorded in [Sprint 4](sprints/04-browser-viewer.md). + +## Postman and physical-device functional testing + +The Docker observer subscribes to `robot-market/test/v1/#`. It displays every received message under that namespace as `message_received`, including arbitrary topics, plain text, any JSON shape, empty messages and literal timestamp placeholders. There is no observer payload schema, timestamp or device identity validation. `topics.observe_all=true` enables this mode. Outside functional observation, command/simulator contracts remain available for their structured round-trip tests. + +The test broker has password authentication but no topic ACL. Any existing test account, including `esp32-lab-001`, may publish and subscribe to `robot-market/test/v1/sim-001/telemetry` or another test topic. Plaintext uses `mqtt://109.122.252.129:1884`; TLS uses `mqtts://109.122.252.129:8884` in Postman. Select a distinct client ID, MQTT 5.0, QoS 1 and click Connect before Send. + +Send `hello from postman` to `robot-market/test/v1/sim-001/telemetry`. The literal payload must appear in the browser at http://127.0.0.1:8080. If it does not, inspect Postman's connection result and publish acknowledgement; the server must receive the publication before the observer can show it. Refresh the browser and clear filters. The simulator can stay stopped. + +Traffic display is separate from protocol-feature evidence: raw receipt demonstrates delivery, not a native MQTT 5 correlated command completion. Resource limits remain: 16 KiB message payload, 4 KiB display preview and bounded event queues/history. Binary payloads display as base64. + +## Device receipt ACKs + +Docker enables `TEST_MQTT5_RECEIPTS__ENABLED=true`. Subscribe the device to `//acks` before sending telemetry/live status or a custom message. The backend sends `type=receipt_ack`, `ok=true`, source topic, timestamp, byte length and SHA-256, echoing optional JSON message_id/request_id. No JSON or identity validation is required. Native MQTT 5 Response Topic and Correlation Data are supported. Commands, ACK/receipt topics, receipt_ack payloads and retained replays are excluded to prevent feedback loops. + +Replies have QoS 1, retain=false. The bounded reply queue reports receipt_ack_sent, receipt_ack_failed or receipt_ack_dropped in logs/UI. A duplicate live delivery can receive another receipt; no exactly-once, persistence or reconnect outbox is promised. Subscribe first: a non-retained reply sent before a subscription is established may be missed. + +Run the independent-client verification with the simulator stopped: + +```bash +python3 scripts/check_receipts.py --credentials esp32-test-kit/connection.txt --ca "$HOME/mqtt-test-credentials/ca.crt" +``` + +This checks plaintext/TLS, MQTT 3.1.1/5, text/arbitrary JSON/empty payloads, no ACK loop, and native MQTT 5 response/correlation echo using unique test topic paths. + +### Reliability evidence in the viewer + +Run the opt-in suite with `MQTT5_INTEGRATION_CREDENTIAL_DIR` pointing at the private client handoff directory (backend/device JSON and CA). Each named remote test writes a structured `scenario_result` to verbose test output and publishes it to `robot-market/test/v1/scenarios/results`. The raw observer exposes these as reported compatibility rows with environment, endpoint, protocol, observation and UTC time. These are test-client reports, not independently verified ESP32 claims. Results are bounded by viewer history and disappear on observer restart. Missing rows remain untested; test publication failure fails the test. + +The raw viewer shows observation time and payload previews; it does not interpret an old retained `online` payload as current device health. A Will's `will_configured_at` is when the Will was created, not the instant the connection broke. See [VERIFICATION.md](VERIFICATION.md) for outcomes and limits. diff --git a/docs/SPRINTS.md b/docs/SPRINTS.md new file mode 100644 index 0000000..59013ce --- /dev/null +++ b/docs/SPRINTS.md @@ -0,0 +1,42 @@ +# Ordered sprints and implementation prompts + +Status: **Sprints 1–3 complete with local/remote MQTT 5 checks; Sprint 4 complete with a live browser check; Sprint 5 implemented with restart verification blocked; Sprints 6–7 awaiting authorization**. + +Read [PLAN.md](PLAN.md) for the design and [../README.md](../README.md) for boundaries. Sprints 1–5 are implemented; Sprint 5 has an unresolved verification gate. Each remaining prompt is a future handoff and requires authorization for that sprint. + +## Execution rules + +- Work only in `/home/ryan/Media/Code/Code/robot-market/test-mqtt-5`. Existing projects are read-only references. +- Read applicable AGENTS.md files and `/home/ryan/.codex/RTK.md`; prefix shell commands with `rtk`. +- Implement only the authorized sprint. Report acceptance criteria, actual checks/results, limitations, and remaining work, then wait unless continuing across sprints was explicitly authorized. +- Do not modify production brokers, credentials, listeners, device repositories, or data. No dangerous device commands. +- Record verification environment accurately. A skipped check is not a pass. Local simulator evidence is not hardware evidence. +- Keep documentation practical: update the existing plan/README and targeted contract/runbook/checklist files; do not add repetitive status reports. +- Proposed file paths are guidance. Keep packages small; do not create abstractions merely to match a directory list. +- Recheck dependency pins before first installation. Explain material deviations. Do not download SDKs or install tools unnecessarily. + +## Sprint files + +Each sprint is stored in its own file with tasks, deliverables, dependencies, acceptance criteria, automated checks, manual verification, risks, a copy-ready prompt, and a mandatory end-of-sprint test/check gate. + +| Sprint | Document | Status | +| --- | --- | --- | +| 1 | [Go foundation, configuration, contracts and event model](sprints/01-foundation.md) | Complete; local checks PASS | +| 2 | [Server broker deployment package](sprints/02-local-broker.md) | Complete; artifacts, server runtime and external connectivity verified | +| 3 | [MQTT 5 backend, simulator and visible message flow](sprints/03-backend-simulator.md) | Complete; unit/race, real CLI and remote TLS/plaintext checks PASS | +| 4 | [Small Go-served browser viewer](sprints/04-browser-viewer.md) | Complete; local/race checks and live browser verification PASS; ESP32 hardware remains untested | +| 5 | [Reliability scenarios and compatibility evidence](sprints/05-reliability.md) | Implemented; ACK/reliability checks PASS, restart verification BLOCKED | +| 6 | [Remote test-broker deployment and connectivity](sprints/06-remote-broker.md) | Not started | +| 7 | [Conditional real-device compatibility](sprints/07-device-compatibility.md) | Not started | + +## Mandatory completion policy + +After every sprint, execute its tests and manual checks, review final changes, fix failures, rerun affected checks, and report acceptance criteria with actual evidence. Missing required verification is BLOCKED, not passed. Do not mark an unverified sprint complete or bypass this gate when authorized to continue across sprints. + +## ESP32 connection reference + +The supplied `Esp32MqttConfig` configures connectivity, not payloads. Go backend/simulator work can proceed without a C serializer. Device integration must check the C library/version, TLS/protocol selection and whether its single publish_topic setting can support the proposed telemetry/status/ACK topics. See [PLAN.md section 6.1](PLAN.md#61-esp32-connection-configuration-supplied-by-user). + +## Approval checkpoint + +Current order: Sprint 2 server package, user deployment and broker details, Sprint 3 local backend/simulator against that broker, Sprint 4 viewer, Sprint 5 reliability, Sprint 6 operational review and conditional Sprint 7 hardware. The user may authorize a subset. Documentation edits do not authorize application implementation, deployment, or hardware changes. diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md new file mode 100644 index 0000000..cdda96a --- /dev/null +++ b/docs/VERIFICATION.md @@ -0,0 +1,60 @@ +# Sprint 5 verification + +Checked 2026-10-07. Scope: isolated `test-mqtt-5` only. Go race tests on local Linux; Docker Go observer at http://127.0.0.1:8080; remote Mosquitto test broker 109.122.252.129, TLS 8884 and plaintext 1884. Independent Python socket clients use the ESP32 test account; Go tests use private backend/simulated-device handoffs. These checks do not prove physical ESP32 behavior. + +Status: implemented with full sprint completion **BLOCKED** by broker restart verification. Receipt ACK functionality is implemented and verified live. + +## Repeatable checks + +Run from the test project. `rtk` is a local command convention; use ordinary Linux commands on the server. + +```sh +rtk env GOCACHE=/tmp/mqtt5-go-cache go test -race ./... +rtk env GOCACHE=/tmp/mqtt5-go-cache go vet ./... +rtk env GOCACHE=/tmp/mqtt5-go-cache MQTT5_INTEGRATION_CREDENTIAL_DIR=/home/ryan/mqtt-test-credentials go test -race -v ./internal/integration +rtk docker compose up -d --build observer +rtk python3 scripts/check_receipts.py --credentials esp32-test-kit/connection.txt --ca esp32-test-kit/ca.crt +rtk docker compose ps -a +``` + +The normal unit suite skips remote tests without the opt-in credential directory; that skip is not remote evidence. The separately executed opt-in suite passed all remote tests. Tests create temporary simulated clients and clean them up; the Docker simulator stays stopped. + +## Results and limits + +| Feature | Outcome | Evidence and limit | +| --- | --- | --- | +| Unit/race regression and vet | PASS | All packages pass; includes ACK routing, arbitrary/empty payloads, binary correlation copying, duplicate receipt delivery, bounded queue overflow, publish failure, worker shutdown, correlation ordering and viewer tests. | +| Device receipt ACKs | PASS | Independent Python subscribers/publishers: MQTT 3.1.1 and 5 × plaintext and TLS; text, arbitrary JSON, empty payloads. Byte count and SHA-256 match original bytes; optional message ID echoed. Incoming message and outgoing receipt confirmed through viewer snapshot. | +| Native receipt routing | PASS | Actual MQTT 5 Response Topic and binary Correlation Data echoed exactly. | +| Loop prevention | PASS | ACK/command/receipt topics and receipt payloads suppressed; retained replay suppressed. Unit tests and bounded live no-extra-reply check. | +| Session resume/loss/expiry | PASS | Real TLS connection interrupted through loopback relay; session-present flag checked; subscriptions restored and fresh commands complete. Pending execution remains unknown after connection loss. | +| Broker-queued expiry | PASS | Persistent subscriber offline; Message Expiry=1 second with still-valid 15-second application deadline; expired packet absent, fresh control delivered after session resume. | +| Device application deadlines | PASS | Existing simulator unit tests prevent expired command execution; remote missing/late/wrong-correlation checks produce timeout/late/unknown rather than success. | +| Harmless commands and duplicates | PASS | Native correlated ping/version over TLS/plaintext; cached application duplicate executes once; conflicting ID rejected. This applies to in-memory simulator command execution, not ESP32 persistence. | +| Wire QoS 1 retransmission | PASS | Independent client sends QoS 1, deliberately does not read PUBACK, drops socket, resumes persistent session, resends same packet ID/payload with DUP=1. Subscriber gets two receipts; this is controlled wire retransmission, not proof of automatic hardware reconnect behavior. | +| Abrupt Will | PASS | Real abrupt TLS loss emits nonretained offline Will; creation timestamp retained, no invented disconnect timestamp. | +| Graceful offline/status and stale display | PASS / limited | Existing simulator graceful status behavior and unit tests; raw viewer labels observed time and does not derive current health from retained payloads. No general device liveness algorithm is claimed. | +| Scenario results | PASS | Opt-in tests publish feature/environment/endpoint/version/status/observation/time; observer logs and viewer expose reported evidence separately from untested ESP32 compatibility. Results are bounded and transient. | +| Authentication/TLS negatives | PASS | Independent external checks reject anonymous CONNECT, invalid password, untrusted CA and wrong hostname; successful TLS/plain sessions provide positive controls. | +| Unrestricted test topic access | PASS | ESP32 account publishes generated device topics and subscribes to their ACKs. ACL enforcement intentionally removed at user's request. | +| Graceful broker restart and persistence | BLOCKED | Server `docker compose exec -T broker true` fails with `OCI runtime exec failed ... procReady not received`. Server `python3 scripts/verify.py --insecure --restart` fails on its initial container health exchange, before restart. Existing broker accepts external MQTT traffic. Resolve server Docker runtime/container-exec issue, then rerun verifier; do not treat prior Sprint 2 evidence as a fresh Sprint 5 pass. | +| Physical ESP32 receipt consumption | Not tested | Developer must subscribe before publish, receive receipt_ack and confirm in firmware logs. User-reported inbound hardware messages are not independent proof of return-path consumption. | + +Final browser refresh verified 21 receipt ACKs, zero reply failures/drops, and the reported Will scenario marked passed while ESP32 native compatibility remained not_tested. Final Docker status: observer running, simulator exited. The regenerated ESP32 ZIP includes the updated wildcard subscription and receipt instructions. + +## Acceptance gate + +- Bounded isolated scenarios and cleanup: PASS for executed scenarios. +- Resume/loss subscription recovery: PASS. +- Expired queued commands not delivered; simulator repeated valid ID executes once: PASS, bounded in-memory behavior only. +- Evidence/environment attached and no simulator results presented as hardware: PASS. +- Logs/viewer expose receipt failures, queue drops, timeout/unknown/late events and evidence limits: PASS. +- Required graceful broker restart/persistence check: BLOCKED. Sprint 5 remains unclosed until verified. + +## Manual device check + +1. Connect using the ESP32 handoff. Subscribe to `robot-market/test/v1/esp32-lab-001/acks` (or the handoff wildcard) and wait for subscription success. +2. Publish any nonretained payload to `robot-market/test/v1/esp32-lab-001/telemetry`. +3. Confirm inbound payload, outbound `receipt_ack_sent`, and ACK counters in the browser. Confirm the device receives JSON with `type=receipt_ack`, `ok=true`, and the original source topic. +4. A receipt means the backend observed bytes. Broker PUBACK only means broker transport acceptance; neither proves device command execution or durable storage. Publishing on /commands or /acks does not create a receipt ACK. +5. Repeat with MQTT 3.1.1 and MQTT 5 if the firmware library supports protocol selection; record the actual library/build and CONNACK behavior. For MQTT 5, test native response/correlation properties explicitly. diff --git a/docs/sprints/01-foundation.md b/docs/sprints/01-foundation.md new file mode 100644 index 0000000..a478feb --- /dev/null +++ b/docs/sprints/01-foundation.md @@ -0,0 +1,104 @@ +# Sprint 1 — Go foundation, configuration, contracts and event model + +Status: complete; verified locally on 2026-10-06. Read [the plan](../PLAN.md), [sprint index and shared rules](../SPRINTS.md), and [README](../../README.md). + + +### Goal and scope + +Produce a runnable configuration-checking CLI and validated message/event contracts without connecting to a broker. + +### Deliverables and likely files + +- `go.mod`, `go.sum`, `.gitignore`. +- `cmd/test-mqtt5/main.go` with configuration validation and help. +- `internal/config/`, `internal/contracts/`, `internal/events/` and focused tests. +- `config.example.toml`, README startup/configuration instructions, `docs/CONTRACTS.md`. + +### Tasks + +1. Verify the proposed Go/koanf/parser/provider pins against official metadata and establish the module. Keep MQTT dependency installation until needed if that avoids unused modules. +2. Implement defaults < TOML < environment < explicitly visited flags. Map `TEST_MQTT5_` with double-underscore nesting; support `--config`, `--device-id`, `--log-level`. +3. Decode typed durations/numbers/bools, reject unknown settings, and validate TLS URL, credentials source, namespace/IDs, QoS, deadlines, certificate pair, limits, simulator and optional web settings. +4. Define telemetry/status/command/ACK structs and validation; allow only ping/get_version. Define explicit ACK stages and application deadlines. +5. Define sanitized event fields, bounded previews, secret redaction, and direction/outcome vocabulary. Do not echo raw malformed or binary payloads. +6. Add signal-aware command structure, contextual errors, and text/JSON slog output. Document examples without secrets and ignore local config/secret/data artifacts. + +### Dependencies and required inputs + +Sprint authorization; an available compatible Go toolchain and dependency access. No server/hardware input is required. + +### Acceptance criteria + +- CLI validates example configuration and exits successfully without MQTT or HTTP connections. +- Explicit CLI overrides win; unspecified flags do not override TOML/environment. Invalid configuration fails with key-specific errors and no secrets. +- Payload examples validate; wrong identity, stage, command, timestamp, size, or missing ID fails. +- Safe event previews preserve useful ping/version input/output while redacting sensitive fields. +- No files in existing projects are changed. + +### Automated checks + +gofmt, go vet, focused config/contracts/events tests; precedence table including empty values and invalid durations; redaction and truncation tests. Run race detection where concurrent code exists. + +### Manual verification + +Run help and configuration validation with defaults/example/local overrides. Inspect text and JSON error output for clarity and secret absence. + +### Risks and unresolved decisions + +Exact toolchain availability and dependency resolution; generated module metadata may reveal incompatible pins. Report and resolve before claiming compatibility. + +### Mandatory end-of-sprint testing and completion gate + +After implementing this sprint, run its automated checks and manual verification above. Inspect the final changes for correctness, scope, secret exposure, and compatibility with completed sprints. Fix failures and rerun affected checks before declaring completion. + +Record the exact commands/checks, actual outcomes, verification environment, and each acceptance criterion as PASS, FAIL, or BLOCKED. Include applicable regression checks for previously completed behavior. A skipped/unavailable required check is BLOCKED, never PASS; do not claim full sprint completion while required checks fail or remain blocked. Do not run unrelated or production-project tests. + +Report the results and remaining limitations to the user. Wait for authorization before the next sprint unless continued execution was explicitly authorized. Even with permission to continue, do not bypass this verification gate. + +### Copy-ready implementation prompt + +```text +Implement Sprint 1 only in /home/ryan/Media/Code/Code/robot-market/test-mqtt-5 after the user authorizes implementation. Read applicable AGENTS.md, /home/ryan/.codex/RTK.md, README.md, docs/PLAN.md and docs/sprints/01-foundation.md. Prefix shell commands with rtk. Existing sibling projects are read-only; do not deploy or contact a production broker. + +Build a small Go CLI that validates configuration without networking. Verify proposed pins (Go 1.27.1; koanf/v2 v2.3.7; TOML v2 parser v2.2.2; file provider v1.2.1; env provider v1.1.0) and report required deviations. Implement typed configuration with defaults < TOML < TEST_MQTT5_ environment < explicitly supplied flags; use __ for nesting and --config. Validate durations, TLS, identities, QoS, session/deadline settings and bounded resource limits. Add versioned telemetry/status/ping/get_version/ACK contracts and validation. Define shared sanitized input/output events for logs and a future browser viewer, including redaction and bounded previews. Add example TOML, secret/data ignore rules and focused contract/configuration docs. + +Use idiomatic Go, slog, contextual errors and signal cancellation. Run formatting, vet and meaningful config/contract/redaction tests. Demonstrate successful config validation and actionable invalid-config errors without leaking secrets. Do not implement MQTT, the viewer, remote deployment or device integration. Report actual checks, limitations and acceptance results, then wait for the next sprint authorization. + +ESP32 reference: read docs/PLAN.md section 6.1 for the supplied Esp32MqttConfig. It is connection configuration, not a payload schema or a blocker for Go implementation. Preserve the proposed Go test contracts. Device-specific MQTT 5 selection depends on the actual C library/build/init; inspect TLS/CA handling, role-specific credentials and single publish_topic limitations during device integration. Do not modify existing C projects. Separate MQTT 3.1.1 payload interoperability from native MQTT 5 feature evidence. + +Mandatory completion gate: after implementing this sprint, run its listed automated and manual checks plus relevant regression checks, inspect the final changes for correctness/scope/secrets/compatibility, fix failures and rerun affected checks. Report exact checks, real outcomes, environment, and every acceptance criterion as PASS/FAIL/BLOCKED. Missing required checks are BLOCKED, never a pass. Do not mark the sprint complete with unresolved failures/blockers or bypass verification when continuing to another sprint. +``` + +## Completion evidence + +Environment: local Linux/amd64, Go 1.27.1. All authored files are confined to test-mqtt-5. No existing server/device project was built, tested or modified. No remote broker or physical device was contacted. + +Pinned Go configuration modules resolved without version deviations; go.mod/go.sum record the resolved versions in this project (no Git commit was created). Paho remains deferred to the MQTT sprint. + +| Acceptance criterion | Result | Evidence | +| --- | --- | --- | +| Example configuration validates without MQTT/HTTP connections | PASS | Built CLI and go run check-config both succeed; output says network_checked=false | +| Explicit flag precedence; unspecified flags preserve file/env | PASS | Configuration precedence tests and CLI override smoke check | +| Invalid config produces key-specific errors without secrets | PASS | Validation table, malformed TOML, unknown keys, invalid identity and secret sentinel smoke checks | +| Valid/invalid payload contracts behave as specified | PASS | Telemetry/status/command/ACK tests; duplicate keys, nulls, deadline boundaries, identity/stage/version/size checks | +| Safe previews preserve useful fields and redact sensitive input | PASS | Preview tests for ping, free-form error redaction, malformed/binary/unknown fields and bounds | +| Existing projects remain unchanged by this work | PASS | All edit commands target the new project; caches and test binary use /tmp | + +Actual checks: + +- `rtk proxy gofmt -l cmd internal`: PASS, no unformatted files. +- `rtk proxy env GOMODCACHE=/tmp/test-mqtt5-mod GOCACHE=/tmp/test-mqtt5-build go vet ./...`: PASS. +- Same cache settings with `go test ./...`: PASS, all four packages. +- Same cache settings with `go test -race ./...`: PASS, all four packages. +- Same cache settings with `go mod verify`: PASS. +- Same cache settings with `go build -buildvcs=false -o /tmp/test-mqtt5-cli ./cmd/test-mqtt5`: PASS. +- Same cache settings with `go run ./cmd/test-mqtt5 --config config.example.toml check-config`: PASS. +- Built CLI help/default/example/explicit flag checks: PASS. +- Built CLI JSON format with secret-bearing environment, invalid identity and malformed secret-bearing TOML: PASS; expected exit codes, values absent from output. +- Canceled-context CLI test: PASS. + +The initial standalone build could not obtain VCS metadata from the surrounding checkout; disabling VCS stamping fixed the build without changing Git state. Initial compilation caught a koanf API mismatch; it was corrected before passing all checks. + +Limits: file validation checks existence/readability/size, not certificate contents or credential correctness. No network/session/ACK correlation/idempotent execution/hardware verification is claimed. Future event producers must sanitize and bound transport property metadata. Remote and physical-device checks: NOT VERIFIED. + +Next: Sprint 2 isolated local TLS broker, after authorization. diff --git a/docs/sprints/02-local-broker.md b/docs/sprints/02-local-broker.md new file mode 100644 index 0000000..c6cd99f --- /dev/null +++ b/docs/sprints/02-local-broker.md @@ -0,0 +1,96 @@ +# Sprint 2 — Server broker deployment package + +Status: COMPLETE. Artifacts, server-container runtime and external TLS/plaintext connection checks passed on 2026-10-06. Read [the plan](../PLAN.md), [sprint index and shared rules](../SPRINTS.md), and [README](../../README.md). + + +### Goal and scope + +Prepare an independent server-deployable Mosquitto Compose package for the user to deploy. Include TLS and an optional authenticated plaintext listener per the user's updated request. Validate artifacts locally; defer runtime checks to the deployed server. + +### Deliverables and likely files + +- `deploy/compose.yaml`, broker configuration/ACL templates and health-check helper. +- Local-only certificate/credential provisioning helper; generated secrets ignored. +- `docs/DEPLOYMENT.md` server setup and verification instructions. + +### Tasks + +1. Verify Mosquitto 2.1.2-alpine image/platform availability and pin its digest; validate version-specific password/ACL plugin configuration. +2. Publish TLS in the base server Compose file and plaintext only with an explicit override; both remain authenticated with identical ACLs. Enable persistence, bounded queues/packets/logs, and correct container ownership/modes. +3. Generate a dedicated local test CA and server certificate with matching server hostname/IP and localhost SANs. Keep keys and generated credentials outside tracked files. +4. Provision separate backend, device and health users. Restrict each device to its own topics; deny production/cross-device access. +5. Add authenticated TLS subscribe/publish/receive health checking with a deadline and dedicated topic. Keep secrets out of arguments/output. +6. Document start/stop/inspect, certificate trust, persistence cleanup, backup, update and rollback. Destructive reset remains explicit and test-instance-only. + +### Dependencies and required inputs + +Sprint 1; available Docker/Compose and registry access. If unavailable, report the blocker and propose an isolated native alternative. Remote server access is not needed. + +### Acceptance criteria + +- Deployed server broker starts and passes an actual authenticated MQTT health exchange. +- Anonymous access, bad credentials, bad CA/hostname and forbidden topics fail as expected. +- Per-device ACLs prevent cross-device publishing/subscription. Commands cannot reach production namespaces. +- Graceful restart preserves selected retained/session state; evidence identifies graceful restart rather than crash durability. +- Configuration and examples contain no real credentials; plaintext host access is enabled only through the explicit override and remains authenticated. + +### Automated checks + +Compose validation, broker config/startup validation using the pinned image, health exchange, positive/negative TLS/auth/ACL smoke checks. Do not print resolved secret-bearing Compose configuration. + +### Manual verification + +Inspect port binding, mounts, ownership, bounded logs and restart behavior. Verify server hostname trust and localhost health checking with the generated CA. + +### Risks and unresolved decisions + +Host container permissions, plugin paths in the exact image, architecture support, port conflicts. Remote OS/DNS/certificate policy remains deferred. + +### Mandatory end-of-sprint testing and completion gate + +After implementing this sprint, run its automated checks and manual verification above. Inspect the final changes for correctness, scope, secret exposure, and compatibility with completed sprints. Fix failures and rerun affected checks before declaring completion. + +Record the exact commands/checks, actual outcomes, verification environment, and each acceptance criterion as PASS, FAIL, or BLOCKED. Include applicable regression checks for previously completed behavior. A skipped/unavailable required check is BLOCKED, never PASS; do not claim full sprint completion while required checks fail or remain blocked. Do not run unrelated or production-project tests. + +Report the results and remaining limitations to the user. Wait for authorization before the next sprint unless continued execution was explicitly authorized. Even with permission to continue, do not bypass this verification gate. + +### Copy-ready implementation prompt + +```text +Implement Sprint 2 only in /home/ryan/Media/Code/Code/robot-market/test-mqtt-5 after authorization. Read applicable AGENTS.md, /home/ryan/.codex/RTK.md, README.md, docs/PLAN.md and docs/sprints/02-local-broker.md. Prefix shell commands with rtk. Existing projects and production brokers are read-only. Do not deploy remotely. + +Build/maintain the separate server Mosquitto deployment package, using verified 2.1.2-alpine and its platform-compatible immutable digest. The user will deploy it and provide endpoint/credential-file details. Check exact 2.1 password/ACL plugin syntax and image paths. Publish TLS in the base Compose file and optional authenticated plaintext in a separate override; do not weaken TLS certificate verification. Disable anonymous access. Add dedicated local test certificate generation, ignored secrets, separate backend/device/health identities, least-privilege per-device ACLs, persistence, bounded broker queues/logs and correct file ownership. Implement an authenticated TLS MQTT subscribe/publish/receive health check with safe secret handling. Document server start/stop/inspect, backup/update/rollback and remote prerequisites. + +Verify real health exchange, invalid credentials/CA/hostname, forbidden/cross-device topics and graceful broker restart persistence. If container tooling is unavailable, report it and propose a native isolated alternative rather than claiming tests passed. Do not implement the Go MQTT application or viewer. Report acceptance results and remaining inputs, then wait. + +ESP32 reference: read docs/PLAN.md section 6.1 for the supplied Esp32MqttConfig. It is connection configuration, not a payload schema or a blocker for Go implementation. Preserve the proposed Go test contracts. Device-specific MQTT 5 selection depends on the actual C library/build/init; inspect TLS/CA handling, role-specific credentials and single publish_topic limitations during device integration. Do not modify existing C projects. Separate MQTT 3.1.1 payload interoperability from native MQTT 5 feature evidence. + +Mandatory completion gate: after implementing this sprint, run its listed automated and manual checks plus relevant regression checks, inspect the final changes for correctness/scope/secrets/compatibility, fix failures and rerun affected checks. Report exact checks, real outcomes, environment, and every acceptance criterion as PASS/FAIL/BLOCKED. Missing required checks are BLOCKED, never a pass. Do not mark the sprint complete with unresolved failures/blockers or bypass verification when continuing to another sprint. +``` + +## Completion evidence and remaining server checks + +User steering: deploy the broker on the server; provide a Compose package for operator deployment; support plaintext alongside TLS for testing. No server changes or permanent local broker were performed. + +| Acceptance criterion | Status | Evidence / remaining action | +| --- | --- | --- | +| Deployable pinned broker config | PASS for artifacts | Verified official multi-platform digest; Mosquitto 2.1.2 --test-config exits 0 in a network-disabled disposable container | +| Health MQTT exchange | PASS | User ran the verifier on the deployed server: authenticated TLS exchange passed | +| Bad credentials / CA / hostname rejected | PASS | Deployed verifier rejected anonymous access, invalid credentials, untrusted CA and wrong hostname | +| Per-device ACL isolation / production topic denial | PASS | Deployed verifier confirmed cross-device publish/read blocking and production topic denial | +| Graceful retained/session persistence | PASS | Deployed verifier confirmed retained status and offline session recovery after graceful restart; crash durability not tested | +| No tracked credentials; optional plaintext explicit | PASS | Generated secrets confined to /tmp for validation; ignored server paths; merged Compose ports/auth configuration checked | + +Checks actually run locally: + +- docker buildx imagetools inspect: 2.1.2-alpine digest verified; bare 2.1.2 tag was absent and corrected. +- docker compose config --quiet for TLS-only and TLS+plaintext: PASS. +- Compose JSON assertions: ports, non-root runtime, read-only mounts/filesystem, immutable image and log limits PASS; no credential environment values. +- Python AST/helper --help and sh -n healthcheck: PASS. +- prepare.py with throwaway /tmp output and owner 1000:1000: PASS, real OpenSSL generation and container-based password hashing. +- Generated hashed passwords/secret modes/CA-key isolation: PASS without displaying values. +- OpenSSL chain, configured hostname, localhost and 127.0.0.1 checks: PASS; wrong hostname rejected. +- Repeat setup refuses overwrite; invalid hostname inputs rejected: PASS. +- Exact pinned image --test-config with temporary mounts and --network none: PASS. No listening broker started. + +On 2026-10-06 the user ran the corrected verify.py --insecure --restart on 109.122.252.129. All server-container checks passed, including optional authenticated plaintext reception. The initial cross-device failure was a verifier exit-code bug; the corrected verifier requires MQTT PUBACK Not authorized (135). Server runtime acceptance is complete. External authenticated MQTT 5 device telemetry publication/backend reception passed from the development computer on TLS 8884 with CA/hostname verification and plaintext 1884, using unique non-retained probes. Physical hardware remains NOT VERIFIED. See DEPLOYMENT.md for operator commands. diff --git a/docs/sprints/03-backend-simulator.md b/docs/sprints/03-backend-simulator.md new file mode 100644 index 0000000..adf8902 --- /dev/null +++ b/docs/sprints/03-backend-simulator.md @@ -0,0 +1,99 @@ +# Sprint 3 — MQTT 5 backend, simulator and visible message flow + +Status: COMPLETE on 2026-10-06; local unit/race, CLI and dedicated remote-broker checks passed. Read [the plan](../PLAN.md), [sprint index and shared rules](../SPRINTS.md), and [README](../../README.md). + + +### Goal and scope + +Run the complete harmless local command/telemetry loop with structured input/output logs and counters. + +### Deliverables and likely files + +- `cmd/device-sim/main.go`; `cmd/test-mqtt5` observe/command modes. +- `internal/mqtt/`, `internal/backend/`, `internal/simulator/`; event buffer and counters. +- Focused unit/integration tests, README quick start and contract updates. + +### Tasks + +- Support the user-requested plaintext test connection through explicit mqtt.allow_plaintext=false-by-default and a mqtt:// URL only when enabled. Keep TLS verification intact, preserve test namespace/ACLs, and update configuration tests/example/docs. + +1. Integrate `github.com/eclipse/paho.golang v0.23.0`/autopaho with verified TLS, deadlines, bounded jittered backoff and configured role/instance IDs. +2. Implement explicit initial Clean Start/session expiry and subscription readiness/restoration. Copy callback input into bounded workers; define visible overflow behavior. +3. Simulator publishes telemetry, retained online status and an offline Will; handles ping/get_version and emits accepted/completed ACKs. +4. Register pending correlation before publish; use exact Response Topic and native Correlation Data; validate device/topic/request ID/properties and transport reason codes. +5. Enforce application deadline plus MQTT Message Expiry; implement bounded deduplication with in-progress entries, cached ACK replay and conflicting-ID rejection. +6. Produce readable text/JSON events for inbound/outbound payload previews/properties and lifecycle outcomes. Separate attempted publication from transport ACK and device completion. +7. Add own-request latency/counters, late ACK classification, bounded buffers and graceful shutdown. No offline command outbox or automatic ambiguous retries. + +### Dependencies and required inputs + +Sprint 1, Sprint 2 server package deployed and verified, broker hostname/ports, private test backend/device credential files and public CA. No physical device is required. + +### Acceptance criteria + +- Observer receives valid telemetry/status; command mode receives correlated ping/version results from the simulator. +- Logs show safe payload preview, topic, direction, QoS, request ID and native properties; no credentials leak. +- Wrong-topic/device/correlation ACKs cannot complete a pending request; late/out-of-order ACKs do not corrupt state. +- An intentional repeated request ID does not repeat simulated execution; conflicting reuse is rejected. +- Missing ACK and disconnect produce truthful timeout/unknown outcomes. SIGINT/SIGTERM exits within the configured bound. +- Session settings, queue/dedup limits and restart limitations are documented. + +### Automated checks + +gofmt, go vet, unit tests and race detector. Real-broker integration for initial exchange, correlation, timeout, duplicate/conflicting request and shutdown. Scope full fault matrix to Sprint 5. + +### Manual verification + +Run observer and simulator in separate terminals, then run command mode with a distinct client ID. Follow one request through publish_attempt, transport_ack, accepted and completed. Compare safe previews to actual test payloads. + +### Risks and unresolved decisions + +Autopaho callback/ACK timing and concurrent state ownership; inspect pinned behavior instead of assuming backpressure guarantees. Transport success is never execution proof. + +### Mandatory end-of-sprint testing and completion gate + +After implementing this sprint, run its automated checks and manual verification above. Inspect the final changes for correctness, scope, secret exposure, and compatibility with completed sprints. Fix failures and rerun affected checks before declaring completion. + +Record the exact commands/checks, actual outcomes, verification environment, and each acceptance criterion as PASS, FAIL, or BLOCKED. Include applicable regression checks for previously completed behavior. A skipped/unavailable required check is BLOCKED, never PASS; do not claim full sprint completion while required checks fail or remain blocked. Do not run unrelated or production-project tests. + +Report the results and remaining limitations to the user. Wait for authorization before the next sprint unless continued execution was explicitly authorized. Even with permission to continue, do not bypass this verification gate. + +### Copy-ready implementation prompt + +```text +Implement Sprint 3 only in /home/ryan/Media/Code/Code/robot-market/test-mqtt-5 after authorization. Read applicable AGENTS.md, /home/ryan/.codex/RTK.md, README.md, docs/PLAN.md and docs/sprints/03-backend-simulator.md. Use rtk for shell commands. Do not edit siblings, production infrastructure, or physical firmware. + +Using the existing configuration/contracts and isolated deployed test broker, implement a Go MQTT 5 observer/command CLI and device simulator with github.com/eclipse/paho.golang v0.23.0/autopaho, never paho.mqtt.golang. Support only ping/get_version plus telemetry/status. Use distinct predictable client IDs, TLS hostname verification, bounded workers/state, initial Clean Start=true, 300-second session expiry, subscription readiness/restoration, bounded jittered reconnect, retained online/offline status and explicit Will semantics. Commands are non-retained QoS 1 with a 15-second default deadline and MQTT Message Expiry. Correlate exact device/topic/request ID and native Response Topic/Correlation Data; inspect MQTT reason codes. Implement bounded request deduplication, cached ACK replay, conflict rejection, late ACKs, and graceful signal shutdown. + +Make inputs/outputs visible in text/JSON slog events with sanitized bounded payload previews and MQTT properties. Distinguish publication attempt, transport acknowledgement, application acceptance and completion; expose counters and own-request latency. Do not introduce a database, offline outbox, HTTP viewer or dangerous commands. Run meaningful unit/race and real-broker happy-path/correlation/timeout/dedup/shutdown tests. Update quick start and contracts, report verified environment and limitations, then wait. + +ESP32 reference: read docs/PLAN.md section 6.1 for the supplied Esp32MqttConfig. It is connection configuration, not a payload schema or a blocker for Go implementation. Preserve the proposed Go test contracts. Device-specific MQTT 5 selection depends on the actual C library/build/init; inspect TLS/CA handling, role-specific credentials and single publish_topic limitations during device integration. Do not modify existing C projects. Separate MQTT 3.1.1 payload interoperability from native MQTT 5 feature evidence. + +Mandatory completion gate: after implementing this sprint, run its listed automated and manual checks plus relevant regression checks, inspect the final changes for correctness/scope/secrets/compatibility, fix failures and rerun affected checks. Report exact checks, real outcomes, environment, and every acceptance criterion as PASS/FAIL/BLOCKED. Missing required checks are BLOCKED, never a pass. Do not mark the sprint complete with unresolved failures/blockers or bypass verification when continuing to another sprint. +``` + +## Completion evidence + +Implemented only in the isolated test project; existing projects and server configuration were not changed. Added pinned Paho v0.23.0/autopaho, shared bounded transport, observer and safe command CLI, simulator, event history/counters and [RUNNING.md](../RUNNING.md). Plaintext requires explicit opt-in; TLS verifies CA and hostname. No browser, database, offline application outbox or physical-device implementation was added. + +| Acceptance criterion | Status | Evidence | +| --- | --- | --- | +| Observer telemetry/status and correlated ping/version | PASS | Remote race tests on TLS 8884 and plaintext 1884; real CLI observer/simulator/command check | +| Safe input/output logs, properties, lifecycle and counters | PASS | CLI JSON logs contain previews, native correlation and publish/transport/accepted/completed stages; copied credential passwords absent; bounded history tests | +| Wrong topic/device/correlation, late/out-of-order ACK handling | PASS | Unit tests reject wrong identity/topic/correlation/retention/response/result/deadline; completion before acceptance remains terminal; remote wrong-correlation and late-ACK tests | +| Repeated request ID and conflicting reuse | PASS | Unit and remote tests replay cached completion with one execution; changed command with same ID is rejected | +| Timeout, disconnect unknown outcome and bounded shutdown | PASS | Remote deadline/late-ACK tests; abrupt relay connection loss returns unknown; CLI SIGTERM and simulator cancellation complete within configured 5-second bound | +| Session/queue/dedup/restart settings documented | PASS | RUNNING.md records limits, overflow, in-memory state and no automatic application retry; real reconnect tests cover Session Present=true and false with subscription restoration | + +Checks run from the new project with Go 1.27.1 and temporary module/build caches: + +- `gofmt -l cmd internal`: no unformatted files. +- `go vet ./...`: PASS. +- `go test -race ./...`: PASS; default remote tests explicitly skip without the opt-in variable, separately verified below. +- `go mod verify`: all modules verified. +- `go build -buildvcs=false` for both CLI binaries: PASS. +- `MQTT5_INTEGRATION_CREDENTIAL_DIR=/home/ryan/mqtt-test-credentials go test -race -v -count=1 -timeout=90s ./internal/integration`: PASS against 109.122.252.129. Covers TLS/plaintext happy paths, native properties, dedup/conflict, deadline/late ACK, local disconnect and actual TLS connection loss/reconnect with retained and lost broker sessions. The relay touches only its own loopback client connection; no broker restart/configuration change. +- Real built CLI processes: observer/simulator/command ping; JSON previews/native properties/lifecycle; no credential output; successful bounded SIGTERM shutdown: PASS. Temporary check script/output lived under /tmp; the reproducible manual commands are in RUNNING.md. +- Structural source review with graphify AST extraction: 20 Go files, 204 nodes and 660 extracted relationships; source and concurrency review completed without adding graph artifacts to the project. + +Verified environment: Go MQTT 5 backend and local simulator against the dedicated remote Mosquitto 2.1.2 broker, both TLS and plaintext. Larger fault/compatibility matrix, crash durability, durable client/dedup recovery, browser viewing and physical ESP32 interoperability remain unverified or scheduled for later sprints. All Sprint 3 acceptance criteria passed; Sprint 4 requires authorization. diff --git a/docs/sprints/04-browser-viewer.md b/docs/sprints/04-browser-viewer.md new file mode 100644 index 0000000..fb772b2 --- /dev/null +++ b/docs/sprints/04-browser-viewer.md @@ -0,0 +1,81 @@ +# Sprint 4 — Small Go-served browser viewer + +Status: COMPLETE for local implementation and verification; ESP32 hardware evidence remains outstanding. Read [the plan](../PLAN.md), [sprint index and shared rules](../SPRINTS.md), and [README](../../README.md). + + +### Goal and scope + +Inspect the same safe message stream through a browser without adding another service or frontend toolchain. + +### Deliverables and likely files + +- `internal/web/` server, embedded HTML/CSS/vanilla JavaScript and tests. +- `internal/events/` bounded snapshot/SSE fan-out if not already present. +- Viewer configuration and README browser instructions. + +### Tasks + +1. Add opt-in viewer on `127.0.0.1:8080` in observation mode; default disabled. +2. Provide read-only snapshot and SSE endpoints using the sanitized event model. Add event IDs, bounded replay, Last-Event-ID handling, gap/reset indicators, heartbeat and cleanup. +3. Show connection/readiness, counters, filterable direction/topic/device/request timeline, bounded payload/property details and command-stage observations. +4. Show compatibility rows with explicit not_tested/pass/fail/unsupported/blocked evidence. Do not infer native MQTT 5 support from a message's mere arrival. +5. Render payloads as text, apply restrictive CSP/Host/Origin handling, use no CORS wildcard/CDN, and enforce viewer/queue limits. +6. Ensure slow/disconnected viewers cannot block MQTT or grow memory; integrate bounded HTTP shutdown. No HTTP command-publishing endpoint. + +### Dependencies and required inputs + +Sprint 3 running locally; browser availability. No design assets, JavaScript package installation, server access or hardware required. + +### Acceptance criteria + +- Opening the loopback page shows live simulator input/output and state changes matching the logs. +- Filters, pause/resume, reconnect, expired replay buffer and empty/disconnected states behave clearly. +- Hostile payload strings render literally; secrets remain redacted; large previews show truncation. +- Multiple/slow viewers obey bounds and cannot affect command completion. +- Compatibility rows distinguish evidence from unknown status; observed ACK timing is not mislabeled as sender-owned latency. + +### Automated checks + +Go HTTP tests for snapshot/SSE/replay/gap, redaction, origin/host validation, cleanup and shutdown; race detector for fan-out. Use browser automation if available; no heavyweight frontend test stack solely for this page. + +### Manual verification + +Open the viewer while simulator traffic flows; run ping from CLI; inspect payload/properties and lifecycle. Disconnect/reopen the page, exercise filters and pause, send a harmless HTML-like test string, and verify literal rendering. + +### Completion evidence + +- `gofmt -w internal/web/server.go internal/web/server_test.go internal/events/store.go internal/events/store_test.go internal/cli/cli.go internal/config/config.go`: PASS. +- `node --check internal/web/assets/app.js`: PASS after fixing the page script parse error discovered during browser review. +- `go test ./internal/web ./internal/events`: PASS, including snapshot escaping, host/origin policy, SSE replay gaps, slow queue behavior and viewer cleanup/limit. Tests use handler concurrency because this sandbox denies test-process localhost listeners. +- Regression checks: `go test ./...`, `go test -race ./...`, `go vet ./...`, `go mod verify`, and `gofmt -l cmd internal`: PASS. +- Local browser verification against the deployed isolated broker: PASS. Observer connected with MQTT 5; simulator retained status and periodic telemetry appeared; the compatibility panel showed backend MQTT 5 passed, JSON contract passed, and native ESP32 round trip not tested. A harmless ping displayed command payload, response topic, correlation data, accepted/completed ACKs, and telemetry. The command CLI reported `pong:true`. Topic filter, pause/resume and clear filters worked. +- One initial browser pass exposed a JavaScript parse error; it was fixed and the page was reloaded successfully. The simulator was stopped after verification. +- Security boundary: listener binds only to validated `127.0.0.1`; snapshots/SSE are read-only, browser strings render through `textContent`, with same-host/origin checks, CSP and bounded event/viewer queues. There is no HTTP publish route. +- Acceptance outcomes: live simulator input/state/counters PASS; filters/pause/resume and browser reload/reconnect PASS; bounded replay/gap and viewer limits PASS by automated tests; hostile string safety PASS by JSON escaping and text-only-sink regression test (an arbitrary HTML-like MQTT message was not sent because the contract rejects free-form fields and the viewer intentionally has no publish endpoint); command stages/observer latency labeling and compatibility evidence PASS. +- Not claimed: ESP32 hardware/MQTT-version compatibility, native MQTT 5 property support on the ESP32, or long-running browser stress testing. Continue in Sprint 5/7 as planned. + +### Risks and unresolved decisions + +Long-lived SSE connections and event gaps; handle them explicitly. Public access/authentication and browser command submission remain outside scope. + +### Mandatory end-of-sprint testing and completion gate + +After implementing this sprint, run its automated checks and manual verification above. Inspect the final changes for correctness, scope, secret exposure, and compatibility with completed sprints. Fix failures and rerun affected checks before declaring completion. + +Record the exact commands/checks, actual outcomes, verification environment, and each acceptance criterion as PASS, FAIL, or BLOCKED. Include applicable regression checks for previously completed behavior. A skipped/unavailable required check is BLOCKED, never PASS; do not claim full sprint completion while required checks fail or remain blocked. Do not run unrelated or production-project tests. + +Report the results and remaining limitations to the user. Wait for authorization before the next sprint unless continued execution was explicitly authorized. Even with permission to continue, do not bypass this verification gate. + +### Copy-ready implementation prompt + +```text +Implement Sprint 4 only in /home/ryan/Media/Code/Code/robot-market/test-mqtt-5 after authorization. Read applicable AGENTS.md, /home/ryan/.codex/RTK.md, README.md, docs/PLAN.md and docs/sprints/04-browser-viewer.md. Prefix shell commands with rtk. Existing projects are read-only; do not deploy remotely. + +Add a minimal optional browser viewer to the working Go backend using net/http, embed, HTML/CSS and vanilla JavaScript. No Node/npm, CDN, framework or separate service. Bind only 127.0.0.1:8080, default disabled. Display the same sanitized input/output events as logs, including topic/direction/device/request filters, bounded payload and MQTT property details, connections/readiness, counters and observed command stages. Provide read-only snapshot plus SSE with event IDs, bounded replay, explicit gaps, viewer limits and slow-client cleanup. Display compatibility statuses and evidence without assuming native support from ordinary traffic. Keep locally measured own-request latency distinct from observer timing. + +Use textContent for untrusted payloads, restrictive CSP and same-origin/Host checks. No HTTP publishing endpoint or public exposure. Verify real simulator traffic in the browser and meaningful Go HTTP/race tests for event replay, cleanup, redaction and shutdown. Update README, report actual browser verification and limitations, then wait. + +ESP32 reference: read docs/PLAN.md section 6.1 for the supplied Esp32MqttConfig. It is connection configuration, not a payload schema or a blocker for Go implementation. Preserve the proposed Go test contracts. Device-specific MQTT 5 selection depends on the actual C library/build/init; inspect TLS/CA handling, role-specific credentials and single publish_topic limitations during device integration. Do not modify existing C projects. Separate MQTT 3.1.1 payload interoperability from native MQTT 5 feature evidence. + +Mandatory completion gate: after implementing this sprint, run its listed automated and manual checks plus relevant regression checks, inspect the final changes for correctness/scope/secrets/compatibility, fix failures and rerun affected checks. Report exact checks, real outcomes, environment, and every acceptance criterion as PASS/FAIL/BLOCKED. Missing required checks are BLOCKED, never a pass. Do not mark the sprint complete with unresolved failures/blockers or bypass verification when continuing to another sprint. +``` diff --git a/docs/sprints/05-reliability.md b/docs/sprints/05-reliability.md new file mode 100644 index 0000000..4af7069 --- /dev/null +++ b/docs/sprints/05-reliability.md @@ -0,0 +1,81 @@ +# Sprint 5 — Reliability scenarios and compatibility evidence + +Status: implemented, with live ACK and reliability checks PASS; full completion BLOCKED by the server container-exec failure preventing the broker restart verifier. See [verification evidence](../VERIFICATION.md). Read [the plan](../PLAN.md), [sprint index and shared rules](../SPRINTS.md), and [README](../../README.md). + + +### Goal and scope + +Turn the working local environment into a repeatable test harness with explicit, evidence-backed feature results. + +### Deliverables and likely files + +- `tests/integration/` or equivalent opt-in Go integration suite; minimal scenario helpers. +- Simulator controls for delayed/missing ACKs and controlled harmless duplicate tests. +- `docs/VERIFICATION.md` scenario checklist and known limitations; event/result output consumable by logs and current-process viewer. + +### Tasks + +1. Implement explicit scenario result records with feature, environment, endpoint/version, status, observation and time; unknown remains not_tested. +2. Exercise broker graceful restart, network interruption, session resume/loss/expiry and subscription restoration. +3. Test queued command expiry separately from device-side expiry after delivery; synchronize clocks and use bounded test deadlines. +4. Test duplicate requests, conflicting IDs, wrong/late/reordered/missing ACKs, invalid payloads and overloaded buffers. +5. Verify Will after abrupt client stop versus graceful offline status, and stale-status handling. +6. Test auth/TLS failures and unrestricted cross-device topic access (the user explicitly disabled ACLs for this test broker). Test and native Response Topic/Correlation Data echo. Attempt actual QoS 1 retransmission with controlled interruption; label unreproduced behavior honestly. +7. Surface structured scenario verdicts in logs and viewer without adding persistent accounting. Document why repeated application publication is different from wire-level DUP retransmission. + +### Added scope: backend receipt ACKs (2026-10-07) + +Implement device publication → backend receipt → device application ACK, separate from broker PUBACK and device replies to commands. Raw observation continues to accept arbitrary payloads without schema, timestamp or device-ID checks. + +- Enable with `receipts.enabled=true` in raw observe mode; Docker enables it. +- Subscribe on the device to `//acks` before publishing. Route by the incoming topic, not JSON or username. A valid in-namespace MQTT 5 Response Topic overrides the default, with exact Correlation Data echoed. +- Reply at QoS 1, retain false: `type=receipt_ack`, `ok=true`, original `source_topic`, observed time, payload byte length and SHA-256; echo optional message_id/request_id without requiring them. +- Never reply to commands, ACK/receipt topics, receipt_ack payloads or retained replays. Repeated live publications may each get a receipt ACK; receipt confirmation does not execute a device action. +- Keep reply queue bounded and log receipt sent/failed/dropped. Broker acknowledgement of the reply does not prove the device consumed it. Do not claim persistence or exactly-once handling. +- Test raw text, empty/invalid JSON, optional IDs, mismatched JSON identity, unchanged timestamp placeholder, native reply routing/correlation, duplicate delivery, reply failure, queue overload, cancellation and ACK-loop prevention. Run live independent subscriber/publisher checks over MQTT 3.1.1 and MQTT 5 with both plaintext/TLS, then inspect UI inbound/outbound events. + +### Dependencies and required inputs + +Sprints 1–4; local broker control and a safe way to interrupt only test connections/containers. Do not change host-wide firewall or unrelated networking. + +### Acceptance criteria + +- Scenarios are bounded, repeatable, isolated, and leave no unrelated processes/network changes. +- Resume and session-loss paths both recover subscriptions and readiness correctly. +- Expired commands are not executed; repeated valid IDs execute once within the documented in-memory retention window. +- Every compatibility verdict contains evidence/environment; simulator results are not marked physical-device results. +- Logs/browser explain failures, gaps, timeouts, late responses and limits without secrets. + +### Automated checks + +Unit/race suite and opt-in real-broker scenarios with explicit pass/fail/skip outcomes and cleanup. Avoid timing-only assertions where an observable state can synchronize the test. + +### Manual verification + +Follow the checklist with the viewer open; observe reconnect, expiry, Will and late ACK events. Review the resulting feature table and distinguish unsupported, blocked and failed. + +### Risks and unresolved decisions + +QoS 1 wire retransmission may be nondeterministic; report not reproduced rather than substituting an application duplicate. Broker graceful persistence is not crash-durability proof. + +### Mandatory end-of-sprint testing and completion gate + +After implementing this sprint, run its automated checks and manual verification above. Inspect the final changes for correctness, scope, secret exposure, and compatibility with completed sprints. Fix failures and rerun affected checks before declaring completion. + +Record the exact commands/checks, actual outcomes, verification environment, and each acceptance criterion as PASS, FAIL, or BLOCKED. Include applicable regression checks for previously completed behavior. A skipped/unavailable required check is BLOCKED, never PASS; do not claim full sprint completion while required checks fail or remain blocked. Do not run unrelated or production-project tests. + +Report the results and remaining limitations to the user. Wait for authorization before the next sprint unless continued execution was explicitly authorized. Even with permission to continue, do not bypass this verification gate. + +### Copy-ready implementation prompt + +```text +Implement Sprint 5 only in /home/ryan/Media/Code/Code/robot-market/test-mqtt-5 after authorization. Read applicable AGENTS.md, /home/ryan/.codex/RTK.md, README.md, docs/PLAN.md and docs/sprints/05-reliability.md. Use rtk. Never alter production brokers, siblings, or host-wide networking. + +Build a focused opt-in real-broker reliability/compatibility harness around the existing Go backend, simulator and local viewer. Cover graceful broker restart, isolated network interruption, session resume versus loss/expiry, subscription restoration, broker-queued Message Expiry, device application deadlines, duplicates/conflicting request IDs, wrong/late/reordered/missing ACKs, Will versus graceful offline state, validation/overload and TLS/auth failures and unrestricted authenticated topic access. Prove native Response Topic/Correlation Data behavior with actual properties. Attempt controlled QoS 1 retransmission separately from deterministic repeated application requests and report unreproduced cases honestly. + +Emit explicit feature/environment/evidence/status results to logs and the existing viewer, leaving untested features not_tested. Keep all state and test operations bounded, provide cleanup, and avoid a database or monitoring stack. Run meaningful unit/race and isolated integration checks. Update docs/VERIFICATION.md and known limitations; distinguish local simulator, remote and hardware evidence. Report results and wait. + +ESP32 reference: read docs/PLAN.md section 6.1 for the supplied Esp32MqttConfig. It is connection configuration, not a payload schema or a blocker for Go implementation. Preserve the proposed Go test contracts. Device-specific MQTT 5 selection depends on the actual C library/build/init; inspect TLS/CA handling, role-specific credentials and single publish_topic limitations during device integration. Do not modify existing C projects. Separate MQTT 3.1.1 payload interoperability from native MQTT 5 feature evidence. + +Mandatory completion gate: after implementing this sprint, run its listed automated and manual checks plus relevant regression checks, inspect the final changes for correctness/scope/secrets/compatibility, fix failures and rerun affected checks. Report exact checks, real outcomes, environment, and every acceptance criterion as PASS/FAIL/BLOCKED. Missing required checks are BLOCKED, never a pass. Do not mark the sprint complete with unresolved failures/blockers or bypass verification when continuing to another sprint. +``` diff --git a/docs/sprints/06-remote-broker.md b/docs/sprints/06-remote-broker.md new file mode 100644 index 0000000..209bcaa --- /dev/null +++ b/docs/sprints/06-remote-broker.md @@ -0,0 +1,68 @@ +# Sprint 6 — Remote test-broker deployment and connectivity + +Status: proposed; not implemented. Read [the plan](../PLAN.md), [sprint index and shared rules](../SPRINTS.md), and [README](../../README.md). + + +### Goal and scope + +Deploy the locally validated broker as a separate remote test instance and verify outbound-only local clients against it. + +### Deliverables and likely files + +- Environment-specific deployment examples with placeholders and final pinned digest. +- `docs/DEPLOYMENT.md` complete remote certificate/firewall/operations runbook. +- Remote entries in `docs/VERIFICATION.md`; no secrets committed. + +### Tasks + +1. Obtain OS/CPU, Docker/Compose support, available resources/port, DNS/SANs, access method and certificate renewal constraints without requesting secret values in chat. +2. Inspect current listeners/services read-only; choose an isolated instance/port and validate artifacts locally before remote mutation. +3. Prepare test-only credentials/ACLs, certificate provisioning/renewal, data/config mounts, health check, ownership, bounded logs and firewall requirements. +4. Present exact remote operations and obtain any required authorization. Then deploy only the authorized test instance. +5. Verify hostname/certificate/auth/ACL behavior from the development computer, simulator exchange, restart/session recovery and health. +6. Document backup/update/rollback with compatible persistence snapshot and renewal verification. Browser viewer remains local. + +### Dependencies and required inputs + +Sprint 5 local verification; remote details/access and explicit authorization for remote changes. If unavailable, finish locally reviewable artifacts and report remote tests blocked. + +### Acceptance criteria + +- Separate remote TLS instance works without changing production listeners/configuration or requiring inbound development-computer access. +- Local backend/simulator exchange messages and show them in logs/viewer through outbound connections. +- Remote security and restart checks have actual evidence; runbook is complete. +- If access is unavailable, the sprint is explicitly incomplete for deployment, not marked verified. + +### Automated checks + +Local artifact validation before deployment; remote health and focused MQTT integration scenarios after authorization. Repeat checks only where remote environment changes behavior. + +### Manual verification + +Inspect remote listening ports, certificate chain/SAN, log bounds, access controls and persistence. Walk through documented update/rollback steps using the test instance where authorized. + +### Risks and unresolved decisions + +Unknown remote environment, existing port conflicts, certificate renewal method and network restrictions. Native deployment is an explicit alternative if Compose is unsuitable. + +### Mandatory end-of-sprint testing and completion gate + +After implementing this sprint, run its automated checks and manual verification above. Inspect the final changes for correctness, scope, secret exposure, and compatibility with completed sprints. Fix failures and rerun affected checks before declaring completion. + +Record the exact commands/checks, actual outcomes, verification environment, and each acceptance criterion as PASS, FAIL, or BLOCKED. Include applicable regression checks for previously completed behavior. A skipped/unavailable required check is BLOCKED, never PASS; do not claim full sprint completion while required checks fail or remain blocked. Do not run unrelated or production-project tests. + +Report the results and remaining limitations to the user. Wait for authorization before the next sprint unless continued execution was explicitly authorized. Even with permission to continue, do not bypass this verification gate. + +### Copy-ready implementation prompt + +```text +Implement Sprint 6 only in /home/ryan/Media/Code/Code/robot-market/test-mqtt-5 after authorization. Read applicable AGENTS.md, /home/ryan/.codex/RTK.md, README.md, docs/PLAN.md and docs/sprints/06-remote-broker.md. Use rtk. Existing projects, production listeners and credentials must remain untouched. + +First obtain/inspect remote OS, CPU, Docker/Compose support, free test port, DNS/certificate constraints and access method without exposing secrets. Prepare and locally validate the dedicated TLS Mosquitto deployment from prior sprints, pinned by compatible digest, with separate test credentials, per-device ACLs, persistence, restrictive secret permissions, bounded logs and authenticated health check. Complete certificate issuance/renewal, firewall, backup/update/rollback procedures. Present concrete remote operations before requesting any still-required authorization. Do not treat documentation authorization as deployment authorization. + +Only after required authorization, deploy the isolated remote test instance and verify outbound-only local backend/simulator connectivity, hostname validation, bad credentials/ACL behavior, restart and health. Keep the browser viewer on local loopback. If access/details are missing, preserve complete local artifacts and report remote work blocked/unverified. Update deployment/verification docs, report evidence by environment and wait. + +ESP32 reference: read docs/PLAN.md section 6.1 for the supplied Esp32MqttConfig. It is connection configuration, not a payload schema or a blocker for Go implementation. Preserve the proposed Go test contracts. Device-specific MQTT 5 selection depends on the actual C library/build/init; inspect TLS/CA handling, role-specific credentials and single publish_topic limitations during device integration. Do not modify existing C projects. Separate MQTT 3.1.1 payload interoperability from native MQTT 5 feature evidence. + +Mandatory completion gate: after implementing this sprint, run its listed automated and manual checks plus relevant regression checks, inspect the final changes for correctness/scope/secrets/compatibility, fix failures and rerun affected checks. Report exact checks, real outcomes, environment, and every acceptance criterion as PASS/FAIL/BLOCKED. Missing required checks are BLOCKED, never a pass. Do not mark the sprint complete with unresolved failures/blockers or bypass verification when continuing to another sprint. +``` diff --git a/docs/sprints/07-device-compatibility.md b/docs/sprints/07-device-compatibility.md new file mode 100644 index 0000000..c362ec2 --- /dev/null +++ b/docs/sprints/07-device-compatibility.md @@ -0,0 +1,71 @@ +# Sprint 7 — Conditional real-device compatibility + +Status: proposed; not implemented. Read [the plan](../PLAN.md), [sprint index and shared rules](../SPRINTS.md), and [README](../../README.md). + + +### Goal and scope + +Determine and verify actual device behavior without modifying existing device projects or operating real machinery. + +### Deliverables and likely files + +- `docs/DEVICE-SETUP.md` with verified framework/client/protocol and test configuration. +- Device-specific compatibility results in `docs/VERIFICATION.md`. +- Only if separately approved: a small independent test firmware/application under `device/` inside this project. + +### Tasks + +- Inspect how Esp32MqttConfig is consumed: host versus URI, ca_cert PEM versus path, TLS hostname checks, selected protocol and library version, and whether publish calls can target telemetry/status/ACK topics separately. Propose only necessary device-side settings; this struct is not a payload contract. + +1. Inspect supplied Linux device and/or ESP32 source read-only; identify framework, exact client version, configured MQTT protocol, TLS/session handling, identities, payloads and command paths. +2. Determine direct ESP32-to-broker versus gateway architecture and select one initial endpoint with the user. +3. Map supported existing behavior to the test contract. Identify required source changes explicitly; do not modify existing projects or silently add a compatibility bridge. +4. Use a dedicated safe test instance/board. If required, propose an isolated test application in this project and obtain authorization before flashing hardware or writing that new device implementation. +5. Configure test credentials/CA/clock/topic identity; verify actual protocol and native-property evidence, telemetry/status, harmless command lifecycle, reconnect, expiry and duplicate handling where supported. +6. Update comparison results and limitations. Leave unavailable features unsupported/not_tested/blocked with reasons. + +### Dependencies and required inputs + +Sprint 5 minimum; Sprint 6 if using the remote broker. Actual device repositories, board model, hardware/access, framework/toolchain and explicit authorization for flashing/test-device changes. Hardware unavailable means this sprint remains blocked; prior simulator work remains useful. + +### Acceptance criteria + +- “Device backend” is resolved using actual source and deployment evidence. +- Exact tested SDK/client/protocol and environment are recorded. +- Only harmless commands run; production/device repositories remain unchanged. +- Native MQTT 5 features are marked passed only with actual end-to-end evidence. JSON-only interoperability is labeled separately. +- No simulator result is substituted for hardware verification. + +### Automated checks + +Approved isolated device builds/contract checks and backend integration tests appropriate to the identified stack. Do not install arbitrary SDKs before choosing the actual target. + +### Manual verification + +Observe device traffic in backend logs/browser; issue ping/get_version; exercise approved device reconnect/restart and record actual outcomes. Inspect test-broker/client evidence of protocol selection. + +### Risks and unresolved decisions + +Hardware availability, flashing permissions, clock/certificate resources, SDK support and unknown device contract. Existing firmware may require changes outside authorized scope; stop at a concrete change proposal rather than modifying it. + +### Mandatory end-of-sprint testing and completion gate + +After implementing this sprint, run its automated checks and manual verification above. Inspect the final changes for correctness, scope, secret exposure, and compatibility with completed sprints. Fix failures and rerun affected checks before declaring completion. + +Record the exact commands/checks, actual outcomes, verification environment, and each acceptance criterion as PASS, FAIL, or BLOCKED. Include applicable regression checks for previously completed behavior. A skipped/unavailable required check is BLOCKED, never PASS; do not claim full sprint completion while required checks fail or remain blocked. Do not run unrelated or production-project tests. + +Report the results and remaining limitations to the user. Wait for authorization before the next sprint unless continued execution was explicitly authorized. Even with permission to continue, do not bypass this verification gate. + +### Copy-ready implementation prompt + +```text +Perform Sprint 7 only after explicit authorization, using /home/ryan/Media/Code/Code/robot-market/test-mqtt-5 as the only writable project. Read applicable AGENTS.md, /home/ryan/.codex/RTK.md, README.md, docs/PLAN.md and docs/sprints/07-device-compatibility.md. Prefix shell commands with rtk. Existing device/server projects are read-only. No vending, motor, payment, credentials-change or OTA commands. + +Obtain the actual Linux device/ESP32 repositories and board/access details. Inspect framework, exact MQTT client/version, configured protocol, TLS/session/topic/identity/correlation behavior. Resolve whether the target is direct ESP32 MQTT or a gateway and identify one initial test endpoint. Map compatibility to the isolated test contract; do not infer MQTT 5 support from broker capability or message delivery. If device changes are needed, present the concrete changes and prefer a separately authorized independent test application inside this project. Do not modify existing firmware or flash hardware without authorization. + +On authorized dedicated test hardware, verify TLS/time, telemetry/status, ping/get_version ACK stages, native Response Topic/Correlation Data, reconnect/session/expiry/dedup behavior where supported. Inspect real connection/property evidence. Document exact versions, setup, results and limits in docs/DEVICE-SETUP.md and docs/VERIFICATION.md. If hardware/source/access is missing, report blocked/not_tested rather than substituting simulator evidence. Report acceptance status and stop. + +ESP32 reference: read docs/PLAN.md section 6.1 for the supplied Esp32MqttConfig. It is connection configuration, not a payload schema or a blocker for Go implementation. Preserve the proposed Go test contracts. Device-specific MQTT 5 selection depends on the actual C library/build/init; inspect TLS/CA handling, role-specific credentials and single publish_topic limitations during device integration. Do not modify existing C projects. Separate MQTT 3.1.1 payload interoperability from native MQTT 5 feature evidence. + +Mandatory completion gate: after implementing this sprint, run its listed automated and manual checks plus relevant regression checks, inspect the final changes for correctness/scope/secrets/compatibility, fix failures and rerun affected checks. Report exact checks, real outcomes, environment, and every acceptance criterion as PASS/FAIL/BLOCKED. Missing required checks are BLOCKED, never a pass. Do not mark the sprint complete with unresolved failures/blockers or bypass verification when continuing to another sprint. +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d9a9825 --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module robot-market/test-mqtt-5 + +go 1.27.1 + +require ( + github.com/eclipse/paho.golang v0.23.0 + github.com/knadh/koanf/parsers/toml/v2 v2.2.2 + github.com/knadh/koanf/providers/env v1.1.0 + github.com/knadh/koanf/providers/file v1.2.1 + github.com/knadh/koanf/v2 v2.3.7 +) + +require ( + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.4.3 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sys v0.35.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5a6c16e --- /dev/null +++ b/go.sum @@ -0,0 +1,40 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvSyR6Mk= +github.com/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWUpL9nsGJTQN04= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= +github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/toml/v2 v2.2.2 h1:wbGxbgzNMsdEpnybeSPpI8sZixARaEr4+sLW+j+/hLM= +github.com/knadh/koanf/parsers/toml/v2 v2.2.2/go.mod h1:JMyUfTKxpuou5VgLw/RXvKXMixIKEwJXALZon+pt0pg= +github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWyJgef9oQMSc= +github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY= +github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= +github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= +github.com/knadh/koanf/v2 v2.3.7 h1:amceufOeoQcq6VFKjm7/ggJ3t0Dkqaxy5fza4j3YgTA= +github.com/knadh/koanf/v2 v2.3.7/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/backend/backend.go b/internal/backend/backend.go new file mode 100644 index 0000000..b5fd1c8 --- /dev/null +++ b/internal/backend/backend.go @@ -0,0 +1,218 @@ +// Package backend observes contracts and correlates only this process's own commands. +package backend + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + "robot-market/test-mqtt-5/internal/contracts" + "robot-market/test-mqtt-5/internal/events" + "robot-market/test-mqtt-5/internal/mqtt" + + "github.com/eclipse/paho.golang/paho" +) + +type Result struct { + Ack *contracts.Ack + Err error +} +type pending struct { + command contracts.Command + correlation []byte + started, expires time.Time + result chan Result + terminal bool + accepted bool +} +type Backend struct { + Client *mqtt.Client + mu sync.Mutex + pending map[string]*pending + receipts chan *paho.Publish + PublishReceipt func(context.Context, *paho.Publish) error +} + +func New(client *mqtt.Client) *Backend { + return &Backend{Client: client, pending: make(map[string]*pending), receipts: make(chan *paho.Publish, client.Config.Limits.InboundQueue), PublishReceipt: client.PublishRaw} +} +func ID() string { + var data [16]byte + if _, err := rand.Read(data[:]); err != nil { + panic("system random source unavailable") + } + return hex.EncodeToString(data[:]) +} +func Timestamp(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) } +func NewCommand(device, name, id string, lifetime time.Duration) contracts.Command { + now := time.Now().UTC() + return contracts.Command{SchemaVersion: 1, DeviceID: device, RequestID: id, Command: name, IssuedAt: Timestamp(now), ExpiresAt: Timestamp(now.Add(lifetime))} +} +func (b *Backend) register(cmd contracts.Command) (*pending, error) { + if err := cmd.Validate(b.Client.Config.Topics.DeviceID); err != nil { + return nil, err + } + if err := cmd.CheckDeadline(time.Now()); err != nil { + return nil, err + } + expires, _ := time.Parse(time.RFC3339Nano, cmd.ExpiresAt) + b.mu.Lock() + defer b.mu.Unlock() + if len(b.pending) >= b.Client.Config.Limits.PendingCommands { + return nil, errors.New("pending command limit reached") + } + if _, ok := b.pending[cmd.RequestID]; ok { + return nil, errors.New("request ID already pending") + } + p := &pending{command: cmd, correlation: []byte(cmd.RequestID), started: time.Now(), expires: expires, result: make(chan Result, 1)} + b.pending[cmd.RequestID] = p + return p, nil +} +func (b *Backend) Command(ctx context.Context, cmd contracts.Command) (*contracts.Ack, error) { + if !b.Client.Ready() { + return nil, mqtt.ErrNotReady + } + p, err := b.register(cmd) + if err != nil { + return nil, err + } + defer func() { b.mu.Lock(); delete(b.pending, cmd.RequestID); b.mu.Unlock() }() + packet := mqtt.Packet(mqtt.Topic(b.Client.Config, "commands"), 1, false, cmd) + packet.Properties.ResponseTopic = mqtt.Topic(b.Client.Config, "acks") + packet.Properties.CorrelationData = append([]byte(nil), p.correlation...) + expiry := uint32(max(1, int(time.Until(p.expires).Seconds()+0.999))) + packet.Properties.MessageExpiry = &expiry + // Register before publishing: accepted/completed can arrive before Publish returns. + publishCtx, stopPublish := context.WithDeadline(ctx, p.expires) + err = b.Client.Publish(publishCtx, packet) + stopPublish() + if err != nil { + // An authenticated correlated terminal ACK is execution evidence even if + // transport confirmation was lost. Never replace it with a generic timeout. + select { + case result := <-p.result: + return result.Ack, result.Err + default: + } + if !time.Now().Before(p.expires) { + b.Client.Record(events.CommandTimeout, events.Internal, packet, cmd.RequestID, "deadline elapsed during publish; execution unknown") + } + return nil, err + } + timer := time.NewTimer(time.Until(p.expires)) + defer timer.Stop() + select { + case result := <-p.result: + return result.Ack, result.Err + case <-ctx.Done(): + return nil, errors.New("command interrupted; execution unknown") + case <-timer.C: + b.Client.Record(events.CommandTimeout, events.Internal, packet, cmd.RequestID, "deadline elapsed; execution unknown") + return nil, errors.New("command deadline elapsed; execution unknown") + } +} +func (b *Backend) Disconnect() { + b.mu.Lock() + defer b.mu.Unlock() + for id, p := range b.pending { + if !p.terminal { + p.terminal = true + p.result <- Result{Err: errors.New("connection lost; execution unknown")} + b.Client.Record(events.Kind("command_unknown"), events.Internal, nil, id, "connection lost; no automatic retry") + } + } +} +func (b *Backend) Handle(packet *paho.Publish) { + if b.Client.Config.Topics.ObserveAll { + b.Client.Record(events.MessageReceived, events.In, packet, "", "raw observation; contract validation disabled") + if packet.Topic == b.Client.Config.Topics.Namespace+"/scenarios/results" { + var result events.ScenarioResult + if json.Unmarshal(packet.Payload, &result) == nil && result.Feature != "" && result.Environment != "" { + b.Client.Events.Add(events.Event{Kind: "scenario_result", Direction: events.In, Topic: packet.Topic, Scenario: &result, Outcome: "reported test evidence; not independently verified hardware"}) + } + } + b.enqueueReceipt(packet) + return + } + device, kind := mqtt.Identity(b.Client.Config, packet.Topic) + msg, err := contracts.Decode(kind, packet.Payload, device) + if err != nil { + b.Client.Record(events.ValidationFailed, events.In, packet, "", "invalid contract: "+err.Error()) + return + } + request := "" + switch v := msg.(type) { + case *contracts.Ack: + request = v.RequestID + case *contracts.Command: + request = v.RequestID + } + b.Client.Record(events.MessageReceived, events.In, packet, request, "") + ack, ok := msg.(*contracts.Ack) + if !ok { + return + } + b.mu.Lock() + defer b.mu.Unlock() + p, ok := b.pending[ack.RequestID] + if !ok || p.terminal || ack.DeviceID != p.command.DeviceID { + b.Client.Record(events.LateAck, events.In, packet, ack.RequestID, "no active own request; latency not measured") + return + } + if !time.Now().Before(p.expires) { + b.Client.Record(events.LateAck, events.In, packet, ack.RequestID, "ack arrived after deadline") + return + } + if packet.Retain || packet.Properties == nil || !bytes.Equal(packet.Properties.CorrelationData, p.correlation) || packet.Properties.ResponseTopic != "" { + b.Client.Record(events.ValidationFailed, events.In, packet, ack.RequestID, "invalid native ACK correlation or retained ACK") + return + } + if ack.Stage == "completed" && ((p.command.Command == "ping" && ack.Result.Pong == nil) || (p.command.Command == "get_version" && ack.Result.Version == "")) { + b.Client.Record(events.ValidationFailed, events.In, packet, ack.RequestID, "result does not match command") + return + } + if ack.Stage == "accepted" { + if !p.accepted { + p.accepted = true + b.Client.Record(events.CommandAccepted, events.In, packet, ack.RequestID, "") + } + return + } + p.terminal = true + elapsed := time.Since(p.started).Milliseconds() + b.Client.Events.Add(events.Event{Kind: events.CommandCompleted, Direction: events.Internal, RequestID: ack.RequestID, DeviceID: ack.DeviceID, Outcome: ack.Stage, LatencyMS: &elapsed}) + if !*ack.OK { + p.result <- Result{Ack: ack, Err: errors.New("device rejected or failed command")} + } else { + p.result <- Result{Ack: ack} + } +} +func (b *Backend) Serve(ctx context.Context) error { + defer b.Disconnect() + workerCtx, cancelWorker := context.WithCancel(ctx) + done := make(chan struct{}) + go func() { defer close(done); b.receiptWorker(workerCtx) }() + defer func() { cancelWorker(); <-done }() + for { + select { + case <-ctx.Done(): + return nil + case err := <-b.Client.Fatal(): + return err + case <-b.Client.Down: + b.Disconnect() + case packet := <-b.Client.Incoming: + b.Handle(packet) + } + } +} +func (b *Backend) Summary() string { + _, counts := b.Client.Events.Snapshot() + return fmt.Sprintf("received=%d; completed=%d; timeouts=%d; late_acks=%d", counts[events.MessageReceived], counts[events.CommandCompleted], counts[events.CommandTimeout], counts[events.LateAck]) +} diff --git a/internal/backend/backend_test.go b/internal/backend/backend_test.go new file mode 100644 index 0000000..1c6798a --- /dev/null +++ b/internal/backend/backend_test.go @@ -0,0 +1,163 @@ +package backend + +import ( + "context" + "io" + "log/slog" + "testing" + "time" + + "github.com/eclipse/paho.golang/paho" + "robot-market/test-mqtt-5/internal/config" + "robot-market/test-mqtt-5/internal/contracts" + "robot-market/test-mqtt-5/internal/events" + "robot-market/test-mqtt-5/internal/mqtt" +) + +func testBackend() *Backend { + c := config.Defaults() + return New(mqtt.New(c, events.NewStore(32, slog.New(slog.NewTextHandler(io.Discard, nil))))) +} +func TestNativeCorrelationAndResult(t *testing.T) { + for _, bad := range []string{"topic", "device", "correlation", "retained", "response", "result", "expired"} { + t.Run(bad, func(t *testing.T) { + b := testBackend() + cmd := NewCommand("sim-001", "ping", ID(), 15*time.Second) + p, err := b.register(cmd) + if err != nil { + t.Fatal(err) + } + yes := true + ack := contracts.Ack{Envelope: cmd.Envelope, RequestID: cmd.RequestID, Timestamp: Timestamp(time.Now()), Stage: "completed", OK: &yes, Result: &contracts.Result{Pong: &yes}} + if bad == "device" { + ack.DeviceID = "other" + } + if bad == "result" { + ack.Result = &contracts.Result{Version: "1.0.0"} + } + packet := mqtt.Packet(mqtt.Topic(b.Client.Config, "acks"), 1, false, ack) + packet.Properties.CorrelationData = []byte(cmd.RequestID) + switch bad { + case "topic": + packet.Topic = "robot-market/test/v1/other/acks" + case "correlation": + packet.Properties.CorrelationData = []byte("wrong") + case "retained": + packet.Retain = true + case "response": + packet.Properties.ResponseTopic = "unexpected" + case "expired": + p.expires = time.Now().Add(-time.Second) + } + b.Handle(packet) + select { + case <-p.result: + t.Fatal("invalid ACK completed request") + default: + } + }) + } + b := testBackend() + cmd := NewCommand("sim-001", "ping", ID(), 15*time.Second) + p, _ := b.register(cmd) + yes := true + ack := contracts.Ack{Envelope: cmd.Envelope, RequestID: cmd.RequestID, Timestamp: Timestamp(time.Now()), Stage: "completed", OK: &yes, Result: &contracts.Result{Pong: &yes}} + packet := mqtt.Packet(mqtt.Topic(b.Client.Config, "acks"), 1, false, ack) + packet.Properties.CorrelationData = []byte(cmd.RequestID) + // Completion may precede accepted. A subsequent accepted/duplicate cannot regress it. + b.Handle(packet) + ack.Stage = "accepted" + ack.Result = nil + accepted := mqtt.Packet(packet.Topic, 1, false, ack) + accepted.Properties.CorrelationData = []byte(cmd.RequestID) + b.Handle(accepted) + b.Handle(packet) + select { + case result := <-p.result: + if result.Err != nil || result.Ack.Stage != "completed" { + t.Fatal("wrong completion") + } + default: + t.Fatal("valid completion ignored") + } + select { + case <-p.result: + t.Fatal("completion delivered twice") + default: + } +} +func TestPendingLimitsAndDisconnect(t *testing.T) { + b := testBackend() + b.Client.Config.Limits.PendingCommands = 1 + cmd := NewCommand("sim-001", "ping", ID(), 15*time.Second) + p, _ := b.register(cmd) + if _, err := b.register(NewCommand("sim-001", "ping", ID(), 15*time.Second)); err == nil { + t.Fatal("capacity ignored") + } + b.Disconnect() + b.Disconnect() + result := <-p.result + if result.Err == nil { + t.Fatal("disconnect claimed success") + } + if _, err := b.Command(context.Background(), cmd); err == nil { + t.Fatal("disconnected command admitted") + } +} + +func TestObserveDeviceAndInvalidPayloadDiagnostics(t *testing.T) { + b := testBackend() + b.Client.Config.Topics.ObserveDevices = "sim-001,esp32-lab-001" + b.Client.Config.Logging.RawPayloads = true + payload := []byte(`{"schema_version":1,"device_id":"esp32-lab-001","message_id":"real-device","boot_id":"boot-1","sequence":1,"timestamp":"2026-10-07T00:00:00Z","readings":{"uptime_s":12}}`) + b.Handle(&paho.Publish{Topic: "robot-market/test/v1/esp32-lab-001/telemetry", Payload: payload}) + b.Handle(&paho.Publish{Topic: "robot-market/test/v1/esp32-lab-001/telemetry", Payload: []byte("hello from esp32")}) + history, counts := b.Client.Events.Snapshot() + if counts[events.MessageReceived] != 1 || counts[events.ValidationFailed] != 1 { + t.Fatal("device receipt and contract failure must be distinct") + } + if history[0].DeviceID != "esp32-lab-001" || history[0].Payload.Text != string(payload) { + t.Fatal("ESP32 identity/payload was lost") + } + if history[1].Payload.Text != "hello from esp32" || history[1].Outcome == "" { + t.Fatal("invalid external payload needs diagnostic visibility") + } + b.Client.Config.Logging.RawPayloads = false + b.Handle(&paho.Publish{Topic: "robot-market/test/v1/esp32-lab-001/telemetry", Payload: []byte("private invalid input")}) + history, _ = b.Client.Events.Snapshot() + if !history[2].Payload.Omitted || history[2].Payload.Text != "" { + t.Fatal("raw payload display must require explicit opt-in") + } +} + +func TestOtherObservedDeviceCannotCompletePendingCommand(t *testing.T) { + b := testBackend() + b.Client.Config.Topics.ObserveDevices = "esp32-lab-001" + cmd := NewCommand("sim-001", "ping", ID(), 15*time.Second) + pending, _ := b.register(cmd) + yes := true + ack := contracts.Ack{Envelope: contracts.Envelope{SchemaVersion: 1, DeviceID: "esp32-lab-001"}, RequestID: cmd.RequestID, Timestamp: Timestamp(time.Now()), Stage: "completed", OK: &yes, Result: &contracts.Result{Pong: &yes}} + packet := mqtt.Packet("robot-market/test/v1/esp32-lab-001/acks", 1, false, ack) + packet.Properties.CorrelationData = []byte(cmd.RequestID) + b.Handle(packet) + select { + case <-pending.result: + t.Fatal("another device completed an own command") + default: + } +} + +func TestRawObservationAcceptsArbitraryPayloadsAndTopics(t *testing.T) { + b := testBackend() + b.Client.Config.Topics.ObserveAll = true + for _, payload := range []string{"hello", `{"device_id":"different","timestamp":"{{$isoTimestamp}}"}`, "", "[1,2,3]"} { + b.Handle(&paho.Publish{Topic: "robot-market/test/v1/arbitrary/custom-topic", Payload: []byte(payload)}) + } + history, counts := b.Client.Events.Snapshot() + if counts[events.MessageReceived] != 4 || counts[events.ValidationFailed] != 0 { + t.Fatal("raw observation incorrectly validated a payload") + } + if history[1].Payload.Text != `{"device_id":"different","timestamp":"{{$isoTimestamp}}"}` || history[1].DeviceID != "arbitrary" { + t.Fatal("raw observation lost payload or topic metadata") + } +} diff --git a/internal/backend/receipts.go b/internal/backend/receipts.go new file mode 100644 index 0000000..d964fe5 --- /dev/null +++ b/internal/backend/receipts.go @@ -0,0 +1,107 @@ +package backend + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" + "time" + + "github.com/eclipse/paho.golang/paho" + "robot-market/test-mqtt-5/internal/events" + "robot-market/test-mqtt-5/internal/mqtt" +) + +// ReceiptAck confirms observation only; it does not claim device execution, +// schema validity, durable storage, or delivery of this reply to the device. +type ReceiptAck struct { + Type string `json:"type"` + OK bool `json:"ok"` + SourceTopic string `json:"source_topic"` + DeviceID string `json:"device_id,omitempty"` + MessageID string `json:"message_id,omitempty"` + RequestID string `json:"request_id,omitempty"` + ReceivedAt string `json:"received_at"` + PayloadBytes int `json:"payload_bytes"` + PayloadSHA256 string `json:"payload_sha256"` +} + +func (b *Backend) receipt(packet *paho.Publish) *paho.Publish { + if packet == nil || packet.Retain || !mqtt.InNamespace(b.Client.Config, packet.Topic) { + return nil + } + parts := strings.Split(packet.Topic, "/") + category := parts[len(parts)-1] + // Receipt messages and command replies must never form an ACK feedback loop. + if category == "acks" || category == "receipts" || category == "commands" { + return nil + } + var fields struct { + Type string `json:"type"` + MessageID string `json:"message_id"` + RequestID string `json:"request_id"` + } + // Extraction is optional: malformed JSON and missing fields still get a reply. + _ = json.Unmarshal(packet.Payload, &fields) + if fields.Type == "receipt_ack" { + return nil + } + device, _ := mqtt.Identity(b.Client.Config, packet.Topic) + topic := b.Client.Config.Topics.Namespace + "/acks" + if device != "" { + topic = b.Client.Config.Topics.Namespace + "/" + device + "/acks" + } + if packet.Properties != nil && packet.Properties.ResponseTopic != "" { + response := packet.Properties.ResponseTopic + if mqtt.InNamespace(b.Client.Config, response) && !strings.ContainsAny(response, "+#\x00") && len(response) <= 256 && response != packet.Topic { + topic = response + } + } + digest := sha256.Sum256(packet.Payload) + ack := ReceiptAck{Type: "receipt_ack", OK: true, SourceTopic: packet.Topic, DeviceID: device, ReceivedAt: Timestamp(time.Now()), PayloadBytes: len(packet.Payload), PayloadSHA256: hex.EncodeToString(digest[:])} + if len(fields.MessageID) <= 256 { + ack.MessageID = fields.MessageID + } + if len(fields.RequestID) <= 256 { + ack.RequestID = fields.RequestID + } + reply := mqtt.Packet(topic, 1, false, ack) + if packet.Properties != nil { + reply.Properties.CorrelationData = append([]byte(nil), packet.Properties.CorrelationData...) + } + return reply +} + +func (b *Backend) enqueueReceipt(packet *paho.Publish) { + if !b.Client.Config.Receipts.Enabled { + return + } + reply := b.receipt(packet) + if reply == nil { + return + } + select { + case b.receipts <- reply: + default: + b.Client.Record(events.Kind("receipt_ack_dropped"), events.Internal, reply, "", "receipt queue full; no reply sent") + } +} + +func (b *Backend) receiptWorker(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case reply := <-b.receipts: + if ctx.Err() != nil { + return + } + if err := b.PublishReceipt(ctx, reply); err != nil { + b.Client.Record(events.Kind("receipt_ack_failed"), events.Internal, reply, "", "reply not confirmed; no automatic retry") + } else { + b.Client.Record(events.Kind("receipt_ack_sent"), events.Out, reply, "", "backend receipt reply acknowledged by broker; device receipt not proven") + } + } + } +} diff --git a/internal/backend/receipts_test.go b/internal/backend/receipts_test.go new file mode 100644 index 0000000..751d8ca --- /dev/null +++ b/internal/backend/receipts_test.go @@ -0,0 +1,108 @@ +package backend + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/eclipse/paho.golang/paho" + "robot-market/test-mqtt-5/internal/events" +) + +func TestReceiptRawPayloadRoutingAndCorrelation(t *testing.T) { + b := testBackend() + b.Client.Config.Topics.ObserveAll = true + for _, payload := range []string{"hello", "", "[1,2]", `{"device_id":"different","timestamp":"{{$isoTimestamp}}","message_id":"msg-1"}`} { + reply := b.receipt(&paho.Publish{Topic: "robot-market/test/v1/real-device/telemetry", Payload: []byte(payload)}) + if reply == nil || reply.Topic != "robot-market/test/v1/real-device/acks" || reply.Retain || reply.QoS != 1 { + t.Fatal("missing/routed/retained reply") + } + var ack ReceiptAck + if json.Unmarshal(reply.Payload, &ack) != nil || !ack.OK || ack.Type != "receipt_ack" || ack.PayloadBytes != len(payload) || len(ack.PayloadSHA256) != 64 { + t.Fatal("invalid receipt") + } + } + p := &paho.Publish{Topic: "robot-market/test/v1/device/telemetry", Payload: []byte("x"), Properties: &paho.PublishProperties{ResponseTopic: "robot-market/test/v1/replies/custom", CorrelationData: []byte{0, 255, 1}}} + reply := b.receipt(p) + p.Properties.CorrelationData[0] = 99 + if reply.Topic != "robot-market/test/v1/replies/custom" || reply.Properties.CorrelationData[0] != 0 || reply.Properties.ResponseTopic != "" { + t.Fatal("native response/correlation lost or aliased") + } + p.Properties.ResponseTopic = "outside/test" + if b.receipt(p).Topic != "robot-market/test/v1/device/acks" { + t.Fatal("unusable response topic should use default receipt topic") + } +} + +func TestReceiptLoopAndRetainedReplayPrevention(t *testing.T) { + b := testBackend() + b.Client.Config.Topics.ObserveAll = true + for _, topic := range []string{"acks", "commands", "receipts"} { + if b.receipt(&paho.Publish{Topic: "robot-market/test/v1/device/" + topic, Payload: []byte("hello")}) != nil { + t.Fatal("ACK loop possible") + } + } + for _, p := range []*paho.Publish{ + {Topic: "robot-market/test/v1/device/status", Retain: true, Payload: []byte("online")}, + {Topic: "robot-market/test/v1/custom/topic", Payload: []byte(`{"type":"receipt_ack","ok":true}`)}, + {Topic: "outside/device/topic", Payload: []byte("hello")}, + } { + if b.receipt(p) != nil { + t.Fatal("reply created for replay, receipt or external topic") + } + } +} + +func TestReceiptDuplicateDeliveryRepliesWithoutCommandExecution(t *testing.T) { + b := testBackend() + b.Client.Config.Topics.ObserveAll = true + b.Client.Config.Receipts.Enabled = true + p := &paho.Publish{Topic: "robot-market/test/v1/device/telemetry", Payload: []byte("same message")} + b.Handle(p) + b.Handle(p) + if len(b.receipts) != 2 || len(b.pending) != 0 { + t.Fatal("duplicate receipt handling must not execute commands") + } +} + +func TestReceiptQueueFailureAndShutdown(t *testing.T) { + b := testBackend() + b.Client.Config.Topics.ObserveAll = true + b.Client.Config.Receipts.Enabled = true + b.receipts = make(chan *paho.Publish, 1) + p := &paho.Publish{Topic: "robot-market/test/v1/device/telemetry", Payload: []byte("hello")} + b.Handle(p) + b.Handle(p) + _, counts := b.Client.Events.Snapshot() + if counts[events.Kind("receipt_ack_dropped")] != 1 { + t.Fatal("queue overflow invisible") + } + b.PublishReceipt = func(context.Context, *paho.Publish) error { return errors.New("test disconnect") } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { b.receiptWorker(ctx); close(done) }() + deadline := time.Now().Add(time.Second) + for { + _, counts = b.Client.Events.Snapshot() + if counts[events.Kind("receipt_ack_failed")] == 1 { + break + } + if time.Now().After(deadline) { + t.Fatal("failure not recorded") + } + time.Sleep(time.Millisecond) + } + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("worker shutdown blocked") + } + history, _ := b.Client.Events.Snapshot() + if !strings.Contains(history[len(history)-1].Outcome, "not confirmed") { + t.Fatal("failure claims success") + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..91c99cf --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,215 @@ +// Package cli shares safe flag parsing and role-specific MQTT startup. +package cli + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "log/slog" + "os" + "time" + + "robot-market/test-mqtt-5/internal/backend" + "robot-market/test-mqtt-5/internal/config" + "robot-market/test-mqtt-5/internal/events" + "robot-market/test-mqtt-5/internal/mqtt" + "robot-market/test-mqtt-5/internal/simulator" + "robot-market/test-mqtt-5/internal/web" +) + +func Run(ctx context.Context, args []string, out, errOut io.Writer, sim bool) int { + name := "test-mqtt5" + if sim { + name = "device-sim" + } + fs := flag.NewFlagSet(name, flag.ContinueOnError) + fs.SetOutput(io.Discard) + path := fs.String("config", "", "TOML file path (optional)") + device := fs.String("device-id", "", "Override topics.device_id") + level := fs.String("log-level", "", "Override logging.level") + credentials := fs.String("credentials", "", "Private deployment client JSON file (username/password only)") + broker := fs.String("broker", "", "Override mqtt.url") + ca := fs.String("ca", "", "Override tls.ca_file") + clientID := fs.String("client-id", "", "Override mqtt.client_id; use a distinct ID for each process") + plaintext := fs.Bool("allow-plaintext", false, "Explicitly permit mqtt:// test connections") + requestID := fs.String("request-id", "", "Command request ID; generated when omitted") + fs.Usage = func() { + if sim { + fmt.Fprintln(out, "Usage: device-sim [FLAGS] [check-config]\nRuns the harmless MQTT 5 simulator by default.") + } else { + fmt.Fprintln(out, "Usage: test-mqtt5 [FLAGS] check-config|observe|command ping|get_version\nFlags precede the subcommand.") + } + fs.SetOutput(out) + fs.PrintDefaults() + fs.SetOutput(io.Discard) + } + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + fmt.Fprintln(errOut, "invalid CLI arguments; use --help (values omitted)") + return 2 + } + mode := "simulate" + command := "" + if !sim { + if fs.NArg() < 1 { + fmt.Fprintln(errOut, "expected check-config, observe or command; use --help") + return 2 + } + mode = fs.Arg(0) + if mode == "command" { + if fs.NArg() != 2 || (fs.Arg(1) != "ping" && fs.Arg(1) != "get_version") { + fmt.Fprintln(errOut, "command requires ping or get_version") + return 2 + } + command = fs.Arg(1) + } else if fs.NArg() != 1 || (mode != "observe" && mode != "check-config") { + fmt.Fprintln(errOut, "expected check-config, observe or command; use --help") + return 2 + } + } else if fs.NArg() != 0 { + if fs.NArg() != 1 || fs.Arg(0) != "check-config" { + fmt.Fprintln(errOut, "simulator accepts only check-config or no subcommand") + return 2 + } + mode = "check-config" + } + if ctx.Err() != nil { + fmt.Fprintln(errOut, "configuration check interrupted") + return 1 + } + overrides := map[string]string{} + defaults := config.Defaults() + // Role defaults remain below TOML, environment and explicit flags. + if sim { + defaults.MQTT.ClientID = "rm5-lab-simulator-01" + defaults.MQTT.Username = "sim-001" + } + if mode == "command" { + defaults.MQTT.ClientID = "rm5-lab-command-" + backend.ID()[:12] + } + if *credentials != "" { + data, err := os.ReadFile(*credentials) + if err != nil || len(data) > 1<<20 { + fmt.Fprintln(errOut, "cannot read private client credentials") + return 1 + } + var record struct { + Username string `json:"username"` + Password string `json:"password"` + } + if json.Unmarshal(data, &record) != nil || record.Username == "" || record.Password == "" { + fmt.Fprintln(errOut, "invalid private client credentials") + return 1 + } + overrides["mqtt.username"] = record.Username + overrides["mqtt.password"] = record.Password + overrides["mqtt.password_file"] = "" + } + fs.Visit(func(f *flag.Flag) { + switch f.Name { + case "device-id": + overrides["topics.device_id"] = *device + case "log-level": + overrides["logging.level"] = *level + case "broker": + overrides["mqtt.url"] = *broker + case "ca": + overrides["tls.ca_file"] = *ca + case "client-id": + overrides["mqtt.client_id"] = *clientID + case "allow-plaintext": + overrides["mqtt.allow_plaintext"] = fmt.Sprint(*plaintext) + } + }) + cfg, err := config.LoadWithDefaults(*path, overrides, defaults) + if err != nil { + fmt.Fprintln(errOut, "configuration invalid:", err) + return 1 + } + if cfg.Web.Enabled && mode != "observe" { + fmt.Fprintln(errOut, "web.enabled requires observe mode") + return 1 + } + if (cfg.Topics.ObserveDevices != "" || cfg.Topics.ObserveAll) && mode != "observe" && mode != "check-config" { + fmt.Fprintln(errOut, "topic observation settings require observe mode") + return 1 + } + if cfg.Receipts.Enabled && (mode != "observe" && mode != "check-config" || !cfg.Topics.ObserveAll) { + fmt.Fprintln(errOut, "receipt replies require raw observe mode") + return 1 + } + var logLevel slog.Level + _ = logLevel.UnmarshalText([]byte(cfg.Logging.Level)) + options := &slog.HandlerOptions{Level: logLevel} + var handler slog.Handler = slog.NewTextHandler(out, options) + if cfg.Logging.Format == "json" { + handler = slog.NewJSONHandler(out, options) + } + log := slog.New(handler) + if mode == "check-config" { + log.Log(ctx, logLevel, "configuration valid", "event", "config_valid", "network_checked", false, "credentials_checked", false) + return 0 + } + if !cfg.MQTT.CleanStart { + fmt.Fprintln(errOut, "runtime requires clean_start=true on initial process connection; durable client recovery is not implemented") + return 1 + } + store := events.NewStore(cfg.Limits.EventHistory, log) + client := mqtt.New(cfg, store) + if sim { + err = simulator.New(client).Run(ctx) + } else { + b := backend.New(client) + var viewer *web.Viewer + if cfg.Web.Enabled { + viewer = web.New(cfg.Web, store, cfg.Deadlines.Shutdown) + if err = viewer.Start(); err != nil { + fmt.Fprintln(errOut, err) + return 1 + } + } + if err = client.Start(ctx, []string{"telemetry", "status", "commands", "acks"}, nil); err == nil { + if mode == "observe" { + err = b.Serve(ctx) + } else { + workerCtx, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + go func() { done <- b.Serve(workerCtx) }() + id := *requestID + if id == "" { + id = backend.ID() + } + _, err = b.Command(ctx, backend.NewCommand(cfg.Topics.DeviceID, command, id, cfg.Deadlines.Command)) + cancel() + workerErr := <-done + if err == nil { + err = workerErr + } + } + closeErr := client.Close() + if err == nil { + err = closeErr + } + } + if viewer != nil { + shutdown, stop := context.WithTimeout(context.Background(), cfg.Deadlines.Shutdown) + viewErr := viewer.Close(shutdown) + stop() + if err == nil { + err = viewErr + } + } + log.Info("backend counters", "event", "counters", "summary", b.Summary()) + } + if err != nil { + fmt.Fprintln(errOut, "operation failed:", err) + return 1 + } + log.Info("shutdown complete", "event", "shutdown_complete", "time", time.Now().UTC()) + return 0 +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..009895d --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,34 @@ +package cli + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPrivateHandoffAndSafeErrors(t *testing.T) { + root := t.TempDir() + credentials := filepath.Join(root, "client.json") + if err := os.WriteFile(credentials, []byte(`{"username":"sim-001","password":"PRIVATE_SENTINEL","host":"unused"}`), 0600); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"--credentials", credentials, "check-config"}, {"--credentials", credentials, "--device-id", "bad/+", "check-config"}} { + var out, errOut bytes.Buffer + Run(context.Background(), args, &out, &errOut, false) + if strings.Contains(out.String()+errOut.String(), "PRIVATE_SENTINEL") { + t.Fatal("private credentials leaked") + } + } + var out, errOut bytes.Buffer + if code := Run(context.Background(), []string{"--broker", "mqtt://localhost:1884", "check-config"}, &out, &errOut, false); code != 1 { + t.Fatal("plaintext not rejected") + } + out.Reset() + errOut.Reset() + if code := Run(context.Background(), []string{"--broker", "mqtt://localhost:1884", "--allow-plaintext", "check-config"}, &out, &errOut, false); code != 0 { + t.Fatal("explicit plaintext not accepted") + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..5c4ee47 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,417 @@ +// Package config loads and validates configuration without opening network connections. +package config + +import ( + "fmt" + "net" + "net/url" + "os" + "reflect" + "regexp" + "strconv" + "strings" + "time" + + "github.com/knadh/koanf/parsers/toml/v2" + "github.com/knadh/koanf/providers/env" + "github.com/knadh/koanf/providers/file" + "github.com/knadh/koanf/v2" +) + +const Prefix = "TEST_MQTT5_" + +type Config struct { + MQTT MQTT `koanf:"mqtt"` + TLS TLS `koanf:"tls"` + Topics Topics `koanf:"topics"` + Deadlines Deadlines `koanf:"deadlines"` + Logging Logging `koanf:"logging"` + Limits Limits `koanf:"limits"` + Simulator Simulator `koanf:"simulator"` + Web Web `koanf:"web"` + Receipts Receipts `koanf:"receipts"` +} +type MQTT struct { + AllowPlaintext bool `koanf:"allow_plaintext"` + URL string `koanf:"url"` + ClientID string `koanf:"client_id"` + Username string `koanf:"username"` + Password string `koanf:"password"` + PasswordFile string `koanf:"password_file"` + QoS int `koanf:"qos"` + CleanStart bool `koanf:"clean_start"` + SessionExpiry time.Duration `koanf:"session_expiry"` + KeepAlive time.Duration `koanf:"keep_alive"` + ReconnectMin time.Duration `koanf:"reconnect_min"` + ReconnectMax time.Duration `koanf:"reconnect_max"` +} +type TLS struct { + CAFile string `koanf:"ca_file"` + ClientCert string `koanf:"client_cert"` + ClientKey string `koanf:"client_key"` +} +type Topics struct { + Namespace string `koanf:"namespace"` + DeviceID string `koanf:"device_id"` + ObserveDevices string `koanf:"observe_devices"` + ObserveAll bool `koanf:"observe_all"` +} +type Deadlines struct { + Connect time.Duration `koanf:"connect"` + Operation time.Duration `koanf:"operation"` + Command time.Duration `koanf:"command"` + Shutdown time.Duration `koanf:"shutdown"` +} +type Logging struct { + Level string `koanf:"level"` + Format string `koanf:"format"` + RawPayloads bool `koanf:"raw_payloads"` +} +type Limits struct { + PayloadBytes int `koanf:"payload_bytes"` + PreviewBytes int `koanf:"preview_bytes"` + PendingCommands int `koanf:"pending_commands"` + InboundQueue int `koanf:"inbound_queue"` + EventHistory int `koanf:"event_history"` + DedupEntries int `koanf:"dedup_entries"` + DedupRetention time.Duration `koanf:"dedup_retention"` +} +type Simulator struct { + TelemetryInterval time.Duration `koanf:"telemetry_interval"` + AckDelay time.Duration `koanf:"ack_delay"` +} +type Receipts struct { + Enabled bool `koanf:"enabled"` +} +type Web struct { + Enabled bool `koanf:"enabled"` + Address string `koanf:"address"` + MaxViewers int `koanf:"max_viewers"` + ViewerQueue int `koanf:"viewer_queue"` +} + +func Defaults() Config { + return Config{ + MQTT: MQTT{URL: "tls://localhost:8883", ClientID: "rm5-lab-observer-01", Username: "test-backend", QoS: 1, CleanStart: true, SessionExpiry: 5 * time.Minute, KeepAlive: 30 * time.Second, ReconnectMin: time.Second, ReconnectMax: 30 * time.Second}, + Topics: Topics{Namespace: "robot-market/test/v1", DeviceID: "sim-001"}, + Deadlines: Deadlines{Connect: 10 * time.Second, Operation: 5 * time.Second, Command: 15 * time.Second, Shutdown: 5 * time.Second}, + Logging: Logging{Level: "info", Format: "text"}, + Limits: Limits{PayloadBytes: 16384, PreviewBytes: 4096, PendingCommands: 128, InboundQueue: 256, EventHistory: 1000, DedupEntries: 1024, DedupRetention: 10 * time.Minute}, + Simulator: Simulator{TelemetryInterval: 5 * time.Second}, + Web: Web{Address: "127.0.0.1:8080", MaxViewers: 8, ViewerQueue: 64}, + } +} + +// Load uses only explicitly supplied overrides. Relative paths are relative to cwd. +// Errors deliberately exclude raw parser errors and values, which may contain secrets. +func Load(path string, overrides map[string]string) (Config, error) { + return LoadWithDefaults(path, overrides, Defaults()) +} + +// LoadWithDefaults applies role defaults below TOML, environment and explicit flags. +func LoadWithDefaults(path string, overrides map[string]string, cfg Config) (Config, error) { + fields := map[string]reflect.Value{} + collect(reflect.ValueOf(&cfg).Elem(), "", fields) + k := koanf.New(".") + for key, v := range fields { + value := v.Interface() + if v.Type() == reflect.TypeFor[time.Duration]() { + value = time.Duration(v.Int()).String() + } + if err := k.Set(key, value); err != nil { + return Config{}, fmt.Errorf("defaults: unable to set configuration") + } + } + if path != "" { + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() { + return Config{}, fmt.Errorf("config: file must exist and be a readable regular file") + } + if info.Size() > 1<<20 { + return Config{}, fmt.Errorf("config: file exceeds 1 MiB") + } + data, err := file.Provider(path).ReadBytes() + if err != nil { + return Config{}, fmt.Errorf("config: unable to read file") + } + if len(data) > 1<<20 { + return Config{}, fmt.Errorf("config: file exceeds 1 MiB") + } + values, err := toml.Parser().Unmarshal(data) + if err != nil { + return Config{}, fmt.Errorf("config: invalid TOML; check syntax (values omitted)") + } + if err = checkKeys(values, "", fields); err != nil { + return Config{}, err + } + if err = setValues(k, values, ""); err != nil { + return Config{}, fmt.Errorf("config: unable to merge TOML") + } + } + // Read flat keys to reject ambiguous names before unflattening. + values, err := env.Provider(Prefix, "", func(key string) string { + return strings.ReplaceAll(strings.ToLower(strings.TrimPrefix(key, Prefix)), "__", ".") + }).Read() + if err != nil { + return Config{}, fmt.Errorf("environment: unable to read settings") + } + for key, value := range values { + if _, ok := fields[key]; !ok { + return Config{}, unknownKey(key) + } + if err = k.Set(key, value); err != nil { + return Config{}, fmt.Errorf("environment: unable to merge settings") + } + } + for key, value := range overrides { + if _, ok := fields[key]; !ok { + return Config{}, unknownKey(key) + } + if err = k.Set(key, value); err != nil { + return Config{}, fmt.Errorf("flags: unable to merge settings") + } + } + for key, field := range fields { + if err := assign(field, k.Get(key)); err != nil { + return Config{}, fmt.Errorf("%s: %s", key, err) + } + } + if err := cfg.Validate(); err != nil { + return Config{}, err + } + return cfg, nil +} + +func setValues(k *koanf.Koanf, values map[string]any, prefix string) error { + for key, value := range values { + if nested, ok := value.(map[string]any); ok { + if err := setValues(k, nested, prefix+key+"."); err != nil { + return err + } + } else if err := k.Set(prefix+key, value); err != nil { + return err + } + } + return nil +} + +func collect(v reflect.Value, prefix string, out map[string]reflect.Value) { + for i := 0; i < v.NumField(); i++ { + key := prefix + v.Type().Field(i).Tag.Get("koanf") + f := v.Field(i) + if f.Kind() == reflect.Struct { + collect(f, key+".", out) + } else { + out[key] = f + } + } +} +func unknownKey(key string) error { + if !regexp.MustCompile(`^[a-z_][a-z0-9_.]{0,80}$`).MatchString(key) { + return fmt.Errorf("configuration: invalid or unknown key (name omitted)") + } + return fmt.Errorf("%s: unknown configuration key", key) +} +func checkKeys(values map[string]any, prefix string, fields map[string]reflect.Value) error { + for key, value := range values { + full := prefix + key + if nested, ok := value.(map[string]any); ok { + known := false + for field := range fields { + if strings.HasPrefix(field, full+".") { + known = true + break + } + } + if !known { + return unknownKey(full) + } + if err := checkKeys(nested, full+".", fields); err != nil { + return err + } + } else if _, ok := fields[full]; !ok { + return unknownKey(full) + } + } + return nil +} +func assign(field reflect.Value, value any) error { + if field.Type() == reflect.TypeFor[time.Duration]() { + s, ok := value.(string) + if !ok { + return fmt.Errorf("expected duration string with unit, e.g. 5s") + } + if !strings.ContainsAny(s, "nhµμmsu") { + return fmt.Errorf("expected duration string with unit, e.g. 5s") + } + d, err := time.ParseDuration(s) + if err != nil { + return fmt.Errorf("invalid or overflowing duration") + } + field.SetInt(int64(d)) + return nil + } + switch field.Kind() { + case reflect.String: + s, ok := value.(string) + if !ok { + return fmt.Errorf("expected string") + } + field.SetString(s) + case reflect.Bool: + b, ok := value.(bool) + if !ok { + s, isString := value.(string) + if !isString || (s != "true" && s != "false") { + return fmt.Errorf("expected true or false") + } + b = s == "true" + } + field.SetBool(b) + case reflect.Int: + var n int64 + switch v := value.(type) { + case int: + n = int64(v) + case int64: + n = v + case string: + parsed, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return fmt.Errorf("expected integer within range") + } + n = parsed + default: + return fmt.Errorf("expected integer") + } + if field.OverflowInt(n) { + return fmt.Errorf("integer out of range") + } + field.SetInt(n) + default: + return fmt.Errorf("unsupported configuration type") + } + return nil +} + +var identifier = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`) + +func (c Config) Validate() error { + bad := func(key, why string) error { return fmt.Errorf("%s: %s", key, why) } + u, err := url.Parse(c.MQTT.URL) + if err != nil || (u.Scheme != "tls" && !(u.Scheme == "mqtt" && c.MQTT.AllowPlaintext)) || u.Hostname() == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" { + return bad("mqtt.url", "use tls://host:port; mqtt:// requires mqtt.allow_plaintext=true; omit credentials, path, query and fragment") + } + port, err := strconv.Atoi(u.Port()) + if err != nil || port < 1 || port > 65535 { + return bad("mqtt.url", "explicit port must be 1..65535") + } + if !identifier.MatchString(c.MQTT.ClientID) { + return bad("mqtt.client_id", "use 1..64 letters, digits, underscores or hyphens, starting with a letter/digit") + } + if !identifier.MatchString(c.Topics.DeviceID) { + return bad("topics.device_id", "use 1..64 safe identifier characters") + } + if c.Topics.ObserveDevices != "" { + devices := strings.Split(c.Topics.ObserveDevices, ",") + if len(devices) > 16 { + return bad("topics.observe_devices", "maximum 16 device IDs") + } + for _, device := range devices { + if !identifier.MatchString(strings.TrimSpace(device)) { + return bad("topics.observe_devices", "use comma-separated safe device IDs") + } + } + } + if c.Topics.Namespace != "robot-market/test/v1" && !strings.HasPrefix(c.Topics.Namespace, "robot-market/test/v1/") { + return bad("topics.namespace", "must remain under robot-market/test/v1") + } + if len(c.Topics.Namespace) > 200 { + return bad("topics.namespace", "too long") + } + for _, part := range strings.Split(c.Topics.Namespace, "/") { + if !identifier.MatchString(part) { + return bad("topics.namespace", "segments must be nonempty safe identifiers") + } + } + if c.MQTT.QoS < 0 || c.MQTT.QoS > 1 { + return bad("mqtt.qos", "only 0 or 1 supported in this test environment") + } + if c.MQTT.Username == "" || len(c.MQTT.Username) > 128 { + return bad("mqtt.username", "required, maximum 128 bytes") + } + if c.MQTT.Password != "" && c.MQTT.PasswordFile != "" { + return bad("mqtt.password_file", "cannot be combined with mqtt.password") + } + if len(c.MQTT.Password) > 4096 { + return bad("mqtt.password", "maximum 4096 bytes") + } + if (c.TLS.ClientCert == "") != (c.TLS.ClientKey == "") { + return bad("tls.client_cert", "client_cert and client_key must be supplied together") + } + for key, path := range map[string]string{"tls.ca_file": c.TLS.CAFile, "tls.client_cert": c.TLS.ClientCert, "tls.client_key": c.TLS.ClientKey, "mqtt.password_file": c.MQTT.PasswordFile} { + if path != "" { + f, err := os.Open(path) + if err != nil { + return bad(key, "file must exist and be readable") + } + info, err := f.Stat() + f.Close() + if err != nil || !info.Mode().IsRegular() || info.Size() == 0 || info.Size() > 1<<20 { + return bad(key, "expected nonempty regular file up to 1 MiB") + } + } + } + if c.MQTT.SessionExpiry < 0 || c.MQTT.SessionExpiry%time.Second != 0 || c.MQTT.SessionExpiry/time.Second > 1<<32-1 { + return bad("mqtt.session_expiry", "must be whole seconds in 0..4294967295") + } + if c.MQTT.KeepAlive < time.Second || c.MQTT.KeepAlive%time.Second != 0 || c.MQTT.KeepAlive/time.Second > 65535 { + return bad("mqtt.keep_alive", "must be whole seconds in 1..65535") + } + for key, d := range map[string]time.Duration{"mqtt.reconnect_min": c.MQTT.ReconnectMin, "mqtt.reconnect_max": c.MQTT.ReconnectMax, "deadlines.connect": c.Deadlines.Connect, "deadlines.operation": c.Deadlines.Operation, "deadlines.shutdown": c.Deadlines.Shutdown, "simulator.telemetry_interval": c.Simulator.TelemetryInterval, "limits.dedup_retention": c.Limits.DedupRetention} { + if d <= 0 || d > 24*time.Hour { + return bad(key, "must be positive and no greater than 24h") + } + } + if c.MQTT.ReconnectMin > c.MQTT.ReconnectMax || c.MQTT.ReconnectMax > 30*time.Second { + return bad("mqtt.reconnect_max", "must be >= reconnect_min and <= 30s") + } + if c.Deadlines.Command < time.Second || c.Deadlines.Command > time.Minute || c.Deadlines.Command%time.Second != 0 { + return bad("deadlines.command", "must be whole seconds in 1s..1m") + } + if c.Limits.DedupRetention <= c.Deadlines.Command { + return bad("limits.dedup_retention", "must exceed command lifetime") + } + if c.Simulator.AckDelay < 0 || c.Simulator.AckDelay > time.Hour { + return bad("simulator.ack_delay", "must be in 0s..1h") + } + if c.Logging.Level != "debug" && c.Logging.Level != "info" && c.Logging.Level != "warn" && c.Logging.Level != "error" { + return bad("logging.level", "must be debug, info, warn or error") + } + if c.Logging.Format != "text" && c.Logging.Format != "json" { + return bad("logging.format", "must be text or json") + } + for key, n := range map[string]int{"limits.pending_commands": c.Limits.PendingCommands, "limits.inbound_queue": c.Limits.InboundQueue, "limits.event_history": c.Limits.EventHistory, "limits.dedup_entries": c.Limits.DedupEntries, "web.viewer_queue": c.Web.ViewerQueue} { + if n < 1 || n > 10000 { + return bad(key, "must be in 1..10000") + } + } + if c.Limits.PayloadBytes < 256 || c.Limits.PayloadBytes > 16384 { + return bad("limits.payload_bytes", "must be in 256..16384") + } + if c.Limits.PreviewBytes < 64 || c.Limits.PreviewBytes > 4096 || c.Limits.PreviewBytes > c.Limits.PayloadBytes { + return bad("limits.preview_bytes", "must be 64..4096 and no larger than payload_bytes") + } + if c.Web.ViewerQueue < 1 || c.Web.ViewerQueue > 256 { + return bad("web.viewer_queue", "must be in 1..256") + } + if c.Web.MaxViewers < 1 || c.Web.MaxViewers > 8 { + return bad("web.max_viewers", "must be 1..8") + } + host, portText, err := net.SplitHostPort(c.Web.Address) + p, portErr := strconv.Atoi(portText) + if err != nil || host != "127.0.0.1" || portErr != nil || p < 1 || p > 65535 { + return bad("web.address", "use 127.0.0.1 with a port in 1..65535") + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..a3ff62f --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,181 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func cleanEnv(t *testing.T) { + t.Helper() + for _, v := range os.Environ() { + key, _, _ := strings.Cut(v, "=") + if strings.HasPrefix(key, Prefix) { + t.Setenv(key, "") + os.Unsetenv(key) + } + } +} +func configFile(t *testing.T, text string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(p, []byte(text), 0600); err != nil { + t.Fatal(err) + } + return p +} +func TestPrecedence(t *testing.T) { + cleanEnv(t) + path := configFile(t, "[topics]\ndevice_id='file-device'\n[logging]\nlevel='debug'\n[mqtt]\nclean_start=false\n") + c, err := Load(path, nil) + if err != nil { + t.Fatal(err) + } + if c.Topics.DeviceID != "file-device" || c.Logging.Level != "debug" || c.MQTT.CleanStart { + t.Fatalf("TOML/default precedence failed") + } + t.Setenv(Prefix+"TOPICS__DEVICE_ID", "env-device") + t.Setenv(Prefix+"MQTT__SESSION_EXPIRY", "10m") + t.Setenv(Prefix+"MQTT__QOS", "0") + t.Setenv(Prefix+"MQTT__CLEAN_START", "true") + c, err = Load(path, nil) + if err != nil { + t.Fatal(err) + } + if c.Topics.DeviceID != "env-device" || c.MQTT.SessionExpiry != 10*time.Minute || c.MQTT.QoS != 0 || !c.MQTT.CleanStart { + t.Fatal("environment conversion failed") + } + c, err = Load(path, map[string]string{"topics.device_id": "cli-device"}) + if err != nil { + t.Fatal(err) + } + if c.Topics.DeviceID != "cli-device" || c.Logging.Level != "debug" { + t.Fatal("explicit overrides failed") + } +} +func TestValidationAndSafeErrors(t *testing.T) { + cleanEnv(t) + cases := []struct{ name, body, key string }{ + {"unknown", "[mqtt]\npasswrod='SUPER_SECRET'", "mqtt.passwrod"}, + {"empty table", "[unexpected]", "unexpected"}, + {"numeric duration", "[mqtt]\nkeep_alive=30", "mqtt.keep_alive"}, + {"unitless duration", "[mqtt]\nkeep_alive='0'", "mqtt.keep_alive"}, + {"overflow duration", "[mqtt]\nsession_expiry='999999999999999999999h'", "mqtt.session_expiry"}, + {"fraction session", "[mqtt]\nsession_expiry='1.5s'", "mqtt.session_expiry"}, + {"session max", "[mqtt]\nsession_expiry='4294967296s'", "mqtt.session_expiry"}, + {"keepalive max", "[mqtt]\nkeep_alive='65536s'", "mqtt.keep_alive"}, + {"negative", "[deadlines]\nconnect='-1s'", "deadlines.connect"}, + {"qos", "[mqtt]\nqos=2", "mqtt.qos"}, + {"fraction integer", "[mqtt]\nqos=0.5", "mqtt.qos"}, + {"type mismatch", "[mqtt]\npassword=123", "mqtt.password"}, + {"plain MQTT", "[mqtt]\nurl='tcp://localhost:1883'", "mqtt.url"}, + {"userinfo", "[mqtt]\nurl='tls://user:SUPER_SECRET@localhost:8883'", "mqtt.url"}, + {"port", "[mqtt]\nurl='tls://localhost:0'", "mqtt.url"}, + {"wildcard", "[topics]\ndevice_id='sim/+'", "topics.device_id"}, + {"production topic", "[topics]\nnamespace='device'", "topics.namespace"}, + {"cert pair", "[tls]\nclient_key='SUPER_SECRET'", "tls.client_cert"}, + {"secret source conflict", "[mqtt]\npassword='SUPER_SECRET'\npassword_file='secrets/password'", "mqtt.password_file"}, + {"missing CA", "[tls]\nca_file='/missing/SUPER_SECRET'", "tls.ca_file"}, + {"queue", "[limits]\ninbound_queue=0", "limits.inbound_queue"}, + {"preview", "[limits]\npreview_bytes=20000", "limits.preview_bytes"}, + {"retention", "[limits]\ndedup_retention='10s'", "limits.dedup_retention"}, + {"command cap", "[deadlines]\ncommand='61s'", "deadlines.command"}, + {"backoff", "[mqtt]\nreconnect_min='31s'", "mqtt.reconnect_max"}, + {"public web", "[web]\naddress='0.0.0.0:8080'", "web.address"}, + {"log level", "[logging]\nlevel='SUPER_SECRET'", "logging.level"}, + {"syntax", "[mqtt]\npassword='SUPER_SECRET", "invalid TOML"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := Load(configFile(t, tc.body), nil) + if err == nil || !strings.Contains(err.Error(), tc.key) { + t.Fatalf("expected %s error, got %v", tc.key, err) + } + if strings.Contains(err.Error(), "SUPER_SECRET") { + t.Fatal("secret leaked") + } + }) + } +} +func TestEmptyEnvAndUnknown(t *testing.T) { + cleanEnv(t) + path := configFile(t, "[mqtt]\npassword='file-secret'\n") + t.Setenv(Prefix+"MQTT__PASSWORD", "") + c, err := Load(path, nil) + if err != nil { + t.Fatal(err) + } + if c.MQTT.Password != "" { + t.Fatal("empty env must clear password") + } + t.Setenv(Prefix+"MQTT__CLEAN_START", "1") + if _, err = Load(path, nil); err == nil { + t.Fatal("numeric boolean accepted") + } + os.Unsetenv(Prefix + "MQTT__CLEAN_START") + t.Setenv(Prefix+"UNKNOWN", "secret") + if _, err = Load(path, nil); err == nil { + t.Fatal("unknown env accepted") + } +} +func TestFilesAndBounds(t *testing.T) { + cleanEnv(t) + if _, err := Load("/missing/config", nil); err == nil { + t.Fatal("missing config accepted") + } + c := Defaults() + c.MQTT.SessionExpiry = 4294967295 * time.Second + c.MQTT.KeepAlive = 65535 * time.Second + if err := c.Validate(); err != nil { + t.Fatal(err) + } + secret := configFile(t, "test-password") + c.MQTT.PasswordFile = secret + if err := c.Validate(); err != nil { + t.Fatal(err) + } + c.MQTT.PasswordFile = t.TempDir() + if err := c.Validate(); err == nil { + t.Fatal("directory accepted as secret") + } +} + +func TestPlaintextRequiresOptIn(t *testing.T) { + cfg := Defaults() + cfg.MQTT.URL = "mqtt://localhost:1884" + if cfg.Validate() == nil { + t.Fatal("plaintext accepted without opt-in") + } + cfg.MQTT.AllowPlaintext = true + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } + cfg.MQTT.URL = "mqtt://user:secret@localhost:1884" + if cfg.Validate() == nil { + t.Fatal("embedded credentials accepted") + } + cfg.MQTT.URL = "tls://localhost:8884" + cfg.MQTT.AllowPlaintext = false + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } +} + +func TestRoleDefaultsRemainBelowConfiguration(t *testing.T) { + defaults := Defaults() + defaults.MQTT.ClientID = "role-default" + file := filepath.Join(t.TempDir(), "role.toml") + if err := os.WriteFile(file, []byte("[mqtt]\nclient_id='file-client'\n"), 0600); err != nil { + t.Fatal(err) + } + cfg, err := LoadWithDefaults(file, nil, defaults) + if err != nil || cfg.MQTT.ClientID != "file-client" { + t.Fatal("role defaults overrode TOML") + } + cfg, err = LoadWithDefaults(file, map[string]string{"mqtt.client_id": "flag-client"}, defaults) + if err != nil || cfg.MQTT.ClientID != "flag-client" { + t.Fatal("explicit flags lost precedence") + } +} diff --git a/internal/contracts/contracts.go b/internal/contracts/contracts.go new file mode 100644 index 0000000..4b065da --- /dev/null +++ b/internal/contracts/contracts.go @@ -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 +} diff --git a/internal/contracts/contracts_test.go b/internal/contracts/contracts_test.go new file mode 100644 index 0000000..13320e5 --- /dev/null +++ b/internal/contracts/contracts_test.go @@ -0,0 +1,94 @@ +package contracts + +import ( + "strings" + "testing" + "time" +) + +const commandJSON = `{"schema_version":1,"device_id":"sim-001","request_id":"req-001","issued_at":"2026-10-06T10:00:00Z","expires_at":"2026-10-06T10:00:15Z","command":"ping"}` + +func TestDecodeAndDeadline(t *testing.T) { + got, err := Decode("commands", []byte(commandJSON), "sim-001") + if err != nil { + t.Fatal(err) + } + cmd := got.(*Command) + issued, _ := time.Parse(time.RFC3339, cmd.IssuedAt) + for _, tc := range []struct { + delta time.Duration + valid bool + }{{-time.Second, false}, {0, true}, {14 * time.Second, true}, {15 * time.Second, false}} { + err = cmd.CheckDeadline(issued.Add(tc.delta)) + if (err == nil) != tc.valid { + t.Fatalf("deadline %s: %v", tc.delta, err) + } + } +} +func TestValidContracts(t *testing.T) { + cases := map[string]string{ + "telemetry": `{"schema_version":1,"device_id":"sim-001","message_id":"evt-1","boot_id":"boot-1","sequence":0,"timestamp":"2026-10-06T10:00:00Z","readings":{"uptime_s":12}}`, + "status": `{"schema_version":1,"device_id":"sim-001","boot_id":"boot-1","state":"offline","will_configured_at":"2026-10-06T10:00:00Z"}`, + "acks": `{"schema_version":1,"device_id":"sim-001","request_id":"req-1","timestamp":"2026-10-06T10:00:00Z","stage":"completed","ok":true,"result":{"pong":true}}`, + } + for kind, payload := range cases { + t.Run(kind, func(t *testing.T) { + if _, err := Decode(kind, []byte(payload), "sim-001"); err != nil { + t.Fatal(err) + } + }) + } +} +func TestRejectUnsafePayloads(t *testing.T) { + for _, payload := range []string{ + `null`, `[]`, commandJSON + `{}`, strings.Repeat("x", MaxPayloadBytes+1), + strings.Replace(commandJSON, `"ping"`, `"vend"`, 1), + strings.Replace(commandJSON, `"request_id":"req-001",`, "", 1), + strings.Replace(commandJSON, `"schema_version":1`, `"schema_version":2`, 1), + strings.Replace(commandJSON, `"command":"ping"`, `"command":"ping","password":"SECRET"`, 1), + strings.Replace(commandJSON, `"command":"ping"`, `"command":"ping","command":"get_version"`, 1), + strings.Replace(commandJSON, `"command":"ping"`, `"command":"ping","COMMAND":"get_version"`, 1), + strings.Replace(commandJSON, `10:00:15Z`, `10:00:00Z`, 1), + strings.Replace(commandJSON, `10:00:15Z`, `10:02:00Z`, 1), + strings.Replace(commandJSON, `10:00:15Z`, `10:00:15+00:00`, 1), + string([]byte{255, 0, 1}), + } { + if _, err := Decode("commands", []byte(payload), "sim-001"); err == nil { + t.Fatal("invalid payload accepted") + } else if strings.Contains(err.Error(), "SECRET") { + t.Fatal("raw payload leaked") + } + } + if _, err := Decode("commands", []byte(commandJSON), "other-device"); err == nil { + t.Fatal("wrong device accepted") + } +} + +func TestNullReadingRejected(t *testing.T) { + payload := `{"schema_version":1,"device_id":"sim-001","message_id":"evt-1","boot_id":"boot-1","sequence":0,"timestamp":"2026-10-06T10:00:00Z","readings":{"uptime_s":null}}` + if _, err := Decode("telemetry", []byte(payload), "sim-001"); err == nil { + t.Fatal("null must not become a numeric zero") + } +} +func TestAckStages(t *testing.T) { + base := `{"schema_version":1,"device_id":"sim-001","request_id":"req-1","timestamp":"2026-10-06T10:00:00Z",` + for _, tc := range []struct { + body string + valid bool + }{ + {`"stage":"accepted","ok":true}`, true}, + {`"stage":"accepted"}`, false}, + {`"stage":"accepted","ok":false}`, false}, + {`"stage":"sent","ok":true}`, false}, + {`"stage":"completed","ok":true}`, false}, + {`"stage":"completed","ok":true,"result":{"version":"v1.0.0"}}`, true}, + {`"stage":"completed","ok":true,"result":{"pong":true,"version":"v1"}}`, false}, + {`"stage":"failed","ok":false,"error":{"code":"expired","message":"too late"}}`, true}, + {`"stage":"failed","ok":true,"error":{"code":"expired","message":"too late"}}`, false}, + } { + _, err := Decode("acks", []byte(base+tc.body), "sim-001") + if (err == nil) != tc.valid { + t.Fatalf("stage case %s: %v", tc.body, err) + } + } +} diff --git a/internal/events/events.go b/internal/events/events.go new file mode 100644 index 0000000..2f5d24e --- /dev/null +++ b/internal/events/events.go @@ -0,0 +1,138 @@ +// Package events defines safe inspection records shared by logs and the future viewer. +package events + +import ( + "encoding/base64" + "encoding/json" + "time" + "unicode/utf8" + + "robot-market/test-mqtt-5/internal/contracts" +) + +type Direction string + +const ( + In Direction = "in" + Out Direction = "out" + Internal Direction = "internal" +) + +type Kind string + +const ( + PublishAttempt Kind = "publish_attempt" + TransportAck Kind = "transport_ack" + MessageReceived Kind = "message_received" + ValidationFailed Kind = "validation_failed" + CommandAccepted Kind = "command_accepted" + CommandCompleted Kind = "command_completed" + CommandTimeout Kind = "command_timeout" + LateAck Kind = "late_ack" + DuplicateRequest Kind = "duplicate_request" + ConnectionUp Kind = "connection_up" + ConnectionDown Kind = "connection_down" + EventGap Kind = "event_gap" +) + +type Properties struct { + ResponseTopic string `json:"response_topic,omitempty"` + CorrelationData []byte `json:"correlation_data,omitempty"` + MessageExpirySeconds *uint32 `json:"message_expiry_seconds,omitempty"` +} + +// Event contains inspection data only. Configuration and connection credentials +// are never attached. Raw MQTT payloads require the explicit diagnostics opt-in. +type Event struct { + Scenario *ScenarioResult `json:"scenario,omitempty"` + ID uint64 `json:"id"` + ObservedAt time.Time `json:"observed_at"` + Kind Kind `json:"kind"` + Direction Direction `json:"direction"` + ClientID string `json:"client_id,omitempty"` + DeviceID string `json:"device_id,omitempty"` + Topic string `json:"topic,omitempty"` + QoS byte `json:"qos"` + Retain bool `json:"retain"` + Duplicate *bool `json:"duplicate,omitempty"` + Payload Preview `json:"payload"` + Properties Properties `json:"properties"` + RequestID string `json:"request_id,omitempty"` + Outcome string `json:"outcome,omitempty"` + LatencyMS *int64 `json:"latency_ms,omitempty"` +} + +// ScenarioResult is reported test evidence, never inferred hardware compatibility. +type ScenarioResult struct { + Feature string `json:"feature"` + Environment string `json:"environment"` + Endpoint string `json:"endpoint"` + Version string `json:"version"` + Status string `json:"status"` + Observation string `json:"observation"` + Time time.Time `json:"time"` +} +type Preview struct { + Bytes int `json:"bytes"` + Text string `json:"text,omitempty"` + Omitted bool `json:"omitted"` + Truncated bool `json:"truncated"` +} + +// SafePreview only renders validated contract fields. Unknown fields (including +// credentials) cause the entire payload to be omitted. Free-form error messages +// are always redacted. The original payload is never retained in this value. +func SafePreview(kind, device string, payload []byte, limit int) Preview { + p := Preview{Bytes: len(payload), Omitted: true} + if limit < 1 { + return p + } + if limit > 4096 { + limit = 4096 + } + msg, err := contracts.Decode(kind, payload, device) + if err != nil { + return p + } + if ack, ok := msg.(*contracts.Ack); ok && ack.Error != nil { + ack.Error.Message = "[redacted]" + } + encoded, err := json.Marshal(msg) + if err != nil { + return p + } + if len(encoded) > limit { + encoded = encoded[:limit] + for len(encoded) > 0 && !utf8.Valid(encoded) { + encoded = encoded[:len(encoded)-1] + } + p.Truncated = true + } + p.Text = string(encoded) + p.Omitted = false + return p +} + +// RawPreview is opt-in diagnostics for this isolated test harness. It displays +// received bytes without treating them as a valid contract or execution evidence. +func RawPreview(payload []byte, limit int) Preview { + p := Preview{Bytes: len(payload)} + if limit < 1 { + p.Omitted = true + return p + } + limit = min(limit, 4096) + text := string(payload) + if !utf8.Valid(payload) { + text = "[base64] " + base64.StdEncoding.EncodeToString(payload) + } + if len(text) > limit { + text = text[:limit] + for !utf8.ValidString(text) { + text = text[:len(text)-1] + } + p.Truncated = true + } + p.Text = text + return p +} diff --git a/internal/events/events_test.go b/internal/events/events_test.go new file mode 100644 index 0000000..bb7d34b --- /dev/null +++ b/internal/events/events_test.go @@ -0,0 +1,55 @@ +package events + +import ( + "encoding/json" + "strings" + "testing" + "unicode/utf8" +) + +const ping = `{"schema_version":1,"device_id":"sim-001","request_id":"req-001","issued_at":"2026-10-06T10:00:00Z","expires_at":"2026-10-06T10:00:15Z","command":"ping"}` + +func TestSafePreviews(t *testing.T) { + p := SafePreview("commands", "sim-001", []byte(ping), 4096) + if p.Omitted || !strings.Contains(p.Text, `"command":"ping"`) || p.Bytes != len(ping) { + t.Fatal("lost useful preview") + } + for _, raw := range []string{`{"password":"SECRET"}`, `{"x":"SECRET`, string([]byte{255, 0}), strings.Replace(ping, `"command":"ping"`, `"command":"ping","token":"SECRET"`, 1)} { + preview := SafePreview("commands", "sim-001", []byte(raw), 4096) + encoded, _ := json.Marshal(preview) + if !preview.Omitted || preview.Text != "" || strings.Contains(string(encoded), "SECRET") { + t.Fatal("unsafe payload exposed") + } + } + ack := `{"schema_version":1,"device_id":"sim-001","request_id":"req-001","timestamp":"2026-10-06T10:00:00Z","stage":"failed","ok":false,"error":{"code":"error","message":"password=SECRET"}}` + p = SafePreview("acks", "sim-001", []byte(ack), 4096) + if p.Omitted || strings.Contains(p.Text, "SECRET") || !strings.Contains(p.Text, "[redacted]") { + t.Fatal("free-form error not redacted") + } +} +func TestPreviewBounds(t *testing.T) { + for _, n := range []int{-1, 0, 1, 64, 100, 4096, 99999} { + p := SafePreview("commands", "sim-001", []byte(ping), n) + if n <= 0 { + if !p.Omitted { + t.Fatal("disabled preview not omitted") + } + continue + } + if len(p.Text) > n || !utf8.ValidString(p.Text) { + t.Fatal("invalid preview bounds") + } + if n < len(ping) && !p.Truncated { + t.Fatal("truncation not labeled") + } + } +} + +func TestRawPreviewIsBounded(t *testing.T) { + for _, data := range [][]byte{[]byte("hello"), []byte("éééé"), {0xff, 0x00}} { + preview := RawPreview(data, 5) + if preview.Omitted || len(preview.Text) > 5 || preview.Bytes != len(data) { + t.Fatal("invalid bounded raw preview") + } + } +} diff --git a/internal/events/store.go b/internal/events/store.go new file mode 100644 index 0000000..e38d591 --- /dev/null +++ b/internal/events/store.go @@ -0,0 +1,144 @@ +package events + +import ( + "log/slog" + "sync" + "time" +) + +// Store retains bounded inspection events; the transport selects safe or opt-in raw previews. +type Notice struct { + Event *Event `json:"event,omitempty"` + Gap uint64 `json:"gap,omitempty"` + ResetID uint64 `json:"reset_id,omitempty"` +} +type Subscriber struct { + channel chan Notice + lost uint64 +} +type Store struct { + mu sync.Mutex + history []Event + capacity int + next uint64 + counts map[Kind]uint64 + log *slog.Logger + nextSub uint64 + subs map[uint64]*Subscriber +} + +func NewStore(capacity int, log *slog.Logger) *Store { + return &Store{capacity: capacity, counts: make(map[Kind]uint64), log: log, subs: make(map[uint64]*Subscriber)} +} +func (s *Store) Add(e Event) { + s.mu.Lock() + s.next++ + e.ID = s.next + e.ObservedAt = time.Now().UTC() + s.counts[e.Kind]++ + if len(s.history) == s.capacity { + copy(s.history, s.history[1:]) + s.history = s.history[:len(s.history)-1] + } + s.history = append(s.history, cloneEvent(e)) + for _, sub := range s.subs { + if sub.lost > 0 { + select { + // Advance the cursor through the current event with the gap notice. + // Leaving the cursor before it would cause a queue-of-one viewer to + // receive a gap forever, since every notice would fill its queue. + case sub.channel <- Notice{Gap: sub.lost + 1, ResetID: e.ID}: + sub.lost = 0 + continue + default: + sub.lost++ + continue + } + } + copyEvent := cloneEvent(e) + select { + case sub.channel <- Notice{Event: ©Event}: + default: + sub.lost++ + } + } + s.mu.Unlock() + s.log.Info(string(e.Kind), "event", e) +} +func (s *Store) Snapshot() ([]Event, map[Kind]uint64) { + s.mu.Lock() + defer s.mu.Unlock() + history := make([]Event, len(s.history)) + for i, event := range s.history { + history[i] = cloneEvent(event) + } + counts := make(map[Kind]uint64, len(s.counts)) + for k, v := range s.counts { + counts[k] = v + } + return history, counts +} + +// Clone pointer fields so snapshot consumers cannot mutate retained history. +func cloneEvent(e Event) Event { + if e.Scenario != nil { + result := *e.Scenario + e.Scenario = &result + } + e.Properties.CorrelationData = append([]byte(nil), e.Properties.CorrelationData...) + if e.Properties.MessageExpirySeconds != nil { + n := *e.Properties.MessageExpirySeconds + e.Properties.MessageExpirySeconds = &n + } + if e.Duplicate != nil { + n := *e.Duplicate + e.Duplicate = &n + } + if e.LatencyMS != nil { + n := *e.LatencyMS + e.LatencyMS = &n + } + return e +} + +// Subscribe atomically captures bounded replay and registers a bounded live queue. +// A cursor outside retained history creates an explicit reset to the oldest event. +func (s *Store) Subscribe(after uint64, queue, maxReplay int) (replay []Event, initialGap *Notice, stream <-chan Notice, unsubscribe func()) { + s.mu.Lock() + s.nextSub++ + id := s.nextSub + sub := &Subscriber{channel: make(chan Notice, queue)} + s.subs[id] = sub + if len(s.history) > 0 { + oldest := s.history[0].ID + newest := s.history[len(s.history)-1].ID + if after > newest { + initialGap = &Notice{Gap: after - newest, ResetID: newest} + after = newest + } else if after < oldest-1 { + initialGap = &Notice{Gap: oldest - 1 - after, ResetID: oldest - 1} + after = oldest - 1 + } + if maxReplay > 0 && newest-after > uint64(maxReplay) { + reset := newest - uint64(maxReplay) + missed := reset - after + if initialGap == nil { + initialGap = &Notice{Gap: missed, ResetID: reset} + } else { + initialGap.Gap += missed + initialGap.ResetID = reset + } + after = reset + } + for _, event := range s.history { + if event.ID > after { + replay = append(replay, cloneEvent(event)) + } + } + } else if after > 0 { + initialGap = &Notice{Gap: after, ResetID: 0} + } + s.mu.Unlock() + var once sync.Once + return replay, initialGap, sub.channel, func() { once.Do(func() { s.mu.Lock(); delete(s.subs, id); close(sub.channel); s.mu.Unlock() }) } +} diff --git a/internal/events/store_test.go b/internal/events/store_test.go new file mode 100644 index 0000000..9403b18 --- /dev/null +++ b/internal/events/store_test.go @@ -0,0 +1,82 @@ +package events + +import ( + "io" + "log/slog" + "sync" + "testing" +) + +func TestStoreBoundsCopiesAndConcurrentCounters(t *testing.T) { + s := NewStore(4, slog.New(slog.NewTextHandler(io.Discard, nil))) + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for n := 0; n < 10; n++ { + s.Add(Event{Kind: MessageReceived, Properties: Properties{CorrelationData: []byte("id")}}) + s.Snapshot() + } + }() + } + wg.Wait() + history, counts := s.Snapshot() + if len(history) != 4 || counts[MessageReceived] != 100 { + t.Fatal("history/counter bound incorrect") + } + for i := 1; i < len(history); i++ { + if history[i].ID <= history[i-1].ID { + t.Fatal("history IDs out of order") + } + } + history[0].Properties.CorrelationData[0] = 'X' + again, _ := s.Snapshot() + if string(again[0].Properties.CorrelationData) != "id" { + t.Fatal("snapshot mutated internal history") + } +} + +func TestSubscribeReplayGapLiveFanoutAndCleanup(t *testing.T) { + s := NewStore(3, slog.New(slog.NewTextHandler(io.Discard, nil))) + for i := 0; i < 5; i++ { + s.Add(Event{Kind: MessageReceived}) + } + replay, gap, stream, unsubscribe := s.Subscribe(0, 1, 1) + if gap == nil || gap.ResetID != 4 || gap.Gap != 4 || len(replay) != 1 || replay[0].ID != 5 { + t.Fatalf("bounded replay gap: %#v %#v", gap, replay) + } + s.Add(Event{Kind: MessageReceived}) + if n := <-stream; n.Event == nil || n.Event.ID != 6 { + t.Fatal("live event fanout failed") + } + unsubscribe() + unsubscribe() + if len(s.subs) != 0 { + t.Fatal("subscriber cleanup failed") + } + _, gap, _, closeSub := s.Subscribe(999, 1, 1) + defer closeSub() + if gap == nil || gap.ResetID != 6 { + t.Fatal("future cursor did not request reset") + } +} +func TestSlowViewerGetsGapWithoutBlockingEvents(t *testing.T) { + s := NewStore(8, slog.New(slog.NewTextHandler(io.Discard, nil))) + s.Add(Event{Kind: MessageReceived}) + _, _, stream, unsubscribe := s.Subscribe(1, 1, 8) + defer unsubscribe() + s.Add(Event{Kind: MessageReceived}) + s.Add(Event{Kind: MessageReceived}) + if n := <-stream; n.Event == nil || n.Event.ID != 2 { + t.Fatal("first bounded event missing") + } + s.Add(Event{Kind: MessageReceived}) + if n := <-stream; n.Gap != 2 || n.ResetID != 4 { + t.Fatalf("missing explicit overflow gap: %#v", n) + } + s.Add(Event{Kind: MessageReceived}) + if n := <-stream; n.Event == nil || n.Event.ID != 5 { + t.Fatalf("unexpected event after overflow gap: %#v", n) + } +} diff --git a/internal/integration/broker_test.go b/internal/integration/broker_test.go new file mode 100644 index 0000000..e71844d --- /dev/null +++ b/internal/integration/broker_test.go @@ -0,0 +1,244 @@ +package integration + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "robot-market/test-mqtt-5/internal/backend" + "robot-market/test-mqtt-5/internal/config" + "robot-market/test-mqtt-5/internal/contracts" + "robot-market/test-mqtt-5/internal/events" + "robot-market/test-mqtt-5/internal/mqtt" + "robot-market/test-mqtt-5/internal/simulator" +) + +func remoteConfig(t *testing.T, role string, plaintext bool) config.Config { + t.Helper() + root := os.Getenv("MQTT5_INTEGRATION_CREDENTIAL_DIR") + if root == "" { + t.Skip("opt-in remote test: set MQTT5_INTEGRATION_CREDENTIAL_DIR") + } + data, err := os.ReadFile(filepath.Join(root, role+".json")) + if err != nil { + t.Fatal("cannot read private credential handoff") + } + var record struct { + Host string `json:"host"` + TLS int `json:"tls_port"` + Plain int `json:"plaintext_port"` + Username string `json:"username"` + Password string `json:"password"` + } + if json.Unmarshal(data, &record) != nil { + t.Fatal("invalid credential handoff") + } + cfg := config.Defaults() + cfg.MQTT.Username = record.Username + cfg.MQTT.Password = record.Password + cfg.MQTT.ClientID = "rm5-integration-" + backend.ID()[:16] + scheme, port := "tls", record.TLS + if plaintext { + scheme, port = "mqtt", record.Plain + cfg.MQTT.AllowPlaintext = true + } + cfg.MQTT.URL = scheme + "://" + record.Host + ":" + jsonNumber(port) + cfg.TLS.CAFile = filepath.Join(root, "ca.crt") + cfg.Simulator.TelemetryInterval = 200 * time.Millisecond + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } + reportScenario(t, cfg) + return cfg +} + +var registeredResults sync.Map + +func reportScenario(t *testing.T, cfg config.Config) { + if _, loaded := registeredResults.LoadOrStore(t, true); loaded { + return + } + t.Cleanup(func() { + registeredResults.Delete(t) + status := "passed" + if t.Failed() { + status = "failed" + } + if t.Skipped() { + status = "blocked" + } + result := events.ScenarioResult{Feature: t.Name(), Environment: "independent Go integration clients and remote test broker; no ESP32 hardware", Endpoint: cfg.MQTT.URL, Version: "MQTT 5 / Mosquitto 2.1.2", Status: status, Observation: "See named Go test assertions and verbose output; application duplicates are separate from wire retransmission", Time: time.Now().UTC()} + data, _ := json.Marshal(result) + t.Log("scenario_result " + string(data)) + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + cfg.MQTT.ClientID = "rm5-result-" + backend.ID()[:12] + cfg.MQTT.SessionExpiry = 0 + cfg.Topics.ObserveAll = true + client := mqtt.New(cfg, store(cfg)) + if err := client.Start(ctx, nil, nil); err != nil { + t.Error("scenario reporting connection failed") + return + } + defer client.Close() + if err := client.PublishRaw(ctx, mqtt.Packet(cfg.Topics.Namespace+"/scenarios/results", 1, false, result)); err != nil { + t.Error("scenario reporting publication failed") + } + }) +} +func jsonNumber(n int) string { data, _ := json.Marshal(n); return string(data) } +func store(cfg config.Config) *events.Store { + return events.NewStore(cfg.Limits.EventHistory, slog.New(slog.NewTextHandler(io.Discard, nil))) +} +func wait(t *testing.T, f func() bool) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for !f() { + if time.Now().After(deadline) { + t.Fatal("condition deadline exceeded") + } + time.Sleep(10 * time.Millisecond) + } +} +func TestRemoteLoop(t *testing.T) { + for _, plain := range []bool{false, true} { + name := "TLS" + if plain { + name = "Plaintext" + } + t.Run(name, func(t *testing.T) { + cfg := remoteConfig(t, "sim-001", plain) + simClient := mqtt.New(cfg, store(cfg)) + sim := simulator.New(simClient) + ctx, cancel := context.WithCancel(context.Background()) + simDone := make(chan error, 1) + go func() { simDone <- sim.Run(ctx) }() + defer func() { + start := time.Now() + cancel() + select { + case err := <-simDone: + if err != nil { + t.Error(err) + } + case <-time.After(6 * time.Second): + t.Error("simulator shutdown blocked") + } + if time.Since(start) > cfg.Deadlines.Shutdown { + t.Error("simulator exceeded configured shutdown bound") + } + }() + wait(t, simClient.Ready) + backendCfg := remoteConfig(t, "test-backend", plain) + backendClient := mqtt.New(backendCfg, store(backendCfg)) + if err := backendClient.Start(ctx, []string{"telemetry", "status", "commands", "acks"}, nil); err != nil { + t.Fatal(err) + } + defer backendClient.Close() + b := backend.New(backendClient) + workerCtx, stop := context.WithCancel(ctx) + done := make(chan error, 1) + go func() { done <- b.Serve(workerCtx) }() + defer func() { + stop() + if err := <-done; err != nil { + t.Error(err) + } + }() + wait(t, func() bool { + history, _ := backendClient.Events.Snapshot() + seenTelemetry, seenStatus := false, false + for _, event := range history { + if event.Kind == events.MessageReceived && event.Topic == mqtt.Topic(backendCfg, "telemetry") { + seenTelemetry = true + } + if event.Kind == events.MessageReceived && event.Topic == mqtt.Topic(backendCfg, "status") && strings.Contains(event.Payload.Text, `"state":"online"`) { + seenStatus = true + } + } + return seenTelemetry && seenStatus + }) + command := backend.NewCommand("sim-001", "ping", backend.ID(), 5*time.Second) + for n := 0; n < 2; n++ { + ack, err := b.Command(ctx, command) + if err != nil || ack == nil || ack.Result.Pong == nil || !*ack.Result.Pong { + t.Fatal("ping/replay did not complete") + } + } + _, counts := simClient.Events.Snapshot() + if counts[events.Kind("simulator_execution")] != 1 || counts[events.DuplicateRequest] < 1 { + t.Fatal("duplicate caused re-execution or replay missing") + } + conflict := command + conflict.Command = "get_version" + if _, err := b.Command(ctx, conflict); err == nil { + t.Fatal("conflicting ID was not rejected") + } + ack, err := b.Command(ctx, backend.NewCommand("sim-001", "get_version", backend.ID(), 5*time.Second)) + if err != nil || ack.Result.Version != "test-sim-1.0.0" { + t.Fatal("version result failed") + } + t.Log("observer telemetry/status, native correlated ping/version, cached duplicate and conflict rejection: PASS") + }) + } +} +func TestRemoteWrongCorrelationTimeoutAndDisconnect(t *testing.T) { + cfg := remoteConfig(t, "sim-001", false) + cfg.Simulator.AckDelay = 2 * time.Second + simClient := mqtt.New(cfg, store(cfg)) + sim := simulator.New(simClient) + ctx, cancel := context.WithCancel(context.Background()) + simDone := make(chan error, 1) + go func() { simDone <- sim.Run(ctx) }() + defer func() { + cancel() + select { + case <-simDone: + case <-time.After(6 * time.Second): + t.Error("sim shutdown blocked") + } + }() + wait(t, simClient.Ready) + bc := remoteConfig(t, "test-backend", false) + client := mqtt.New(bc, store(bc)) + if err := client.Start(ctx, []string{"acks"}, nil); err != nil { + t.Fatal(err) + } + b := backend.New(client) + workerCtx, stop := context.WithCancel(ctx) + done := make(chan error, 1) + go func() { done <- b.Serve(workerCtx) }() + defer func() { stop(); <-done }() + command := backend.NewCommand("sim-001", "ping", backend.ID(), time.Second) + commandDone := make(chan error, 1) + go func() { _, err := b.Command(ctx, command); commandDone <- err }() + wait(t, func() bool { _, counts := client.Events.Snapshot(); return counts[events.PublishAttempt] >= 1 }) + yes := true + badAck := contracts.Ack{Envelope: command.Envelope, RequestID: command.RequestID, Timestamp: backend.Timestamp(time.Now()), Stage: "completed", OK: &yes, Result: &contracts.Result{Pong: &yes}} + packet := mqtt.Packet(mqtt.Topic(cfg, "acks"), 1, false, badAck) + packet.Properties.CorrelationData = []byte("wrong-correlation") + if err := simClient.Publish(ctx, packet); err != nil { + t.Fatal("invalid-correlation test publication failed") + } + if err := <-commandDone; err == nil || !strings.Contains(err.Error(), "deadline") { + t.Fatal("invalid correlation completed request or truthful timeout missing") + } + wait(t, func() bool { _, counts := client.Events.Snapshot(); return counts[events.LateAck] > 0 }) + command = backend.NewCommand("sim-001", "ping", backend.ID(), 5*time.Second) + go func() { _, err := b.Command(ctx, command); commandDone <- err }() + wait(t, func() bool { _, counts := client.Events.Snapshot(); return counts[events.PublishAttempt] >= 2 }) + if err := client.Close(); err != nil { + t.Fatal(err) + } + if err := <-commandDone; err == nil { + t.Fatal("disconnect claimed execution success") + } + t.Log("wrong correlation ignored, missing valid completion timeout, late ACK classification and disconnect unknown outcome: PASS") +} diff --git a/internal/integration/reconnect_test.go b/internal/integration/reconnect_test.go new file mode 100644 index 0000000..a328b8b --- /dev/null +++ b/internal/integration/reconnect_test.go @@ -0,0 +1,172 @@ +package integration + +import ( + "context" + "io" + "net" + "strings" + "sync" + "testing" + "time" + + "robot-market/test-mqtt-5/internal/backend" + "robot-market/test-mqtt-5/internal/events" + "robot-market/test-mqtt-5/internal/mqtt" + "robot-market/test-mqtt-5/internal/simulator" +) + +// A loopback byte relay drops only this test client's connection. No broker changes. +type relay struct { + listener net.Listener + target string + mu sync.Mutex + paused bool + connections map[net.Conn]bool + wg sync.WaitGroup +} + +func newRelay(t *testing.T, target string) *relay { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + r := &relay{listener: listener, target: target, connections: make(map[net.Conn]bool)} + r.wg.Add(1) + go func() { + defer r.wg.Done() + for { + local, err := listener.Accept() + if err != nil { + return + } + r.mu.Lock() + if r.paused { + r.mu.Unlock() + local.Close() + continue + } + r.connections[local] = true + r.wg.Add(1) + r.mu.Unlock() + go r.forward(local) + } + }() + return r +} +func (r *relay) forward(local net.Conn) { + defer r.wg.Done() + defer local.Close() + defer func() { r.mu.Lock(); delete(r.connections, local); r.mu.Unlock() }() + remote, err := net.DialTimeout("tcp", r.target, 3*time.Second) + if err != nil { + return + } + defer remote.Close() + r.mu.Lock() + if r.paused { + r.mu.Unlock() + return + } + r.connections[remote] = true + r.mu.Unlock() + defer func() { r.mu.Lock(); delete(r.connections, remote); r.mu.Unlock() }() + done := make(chan struct{}) + go func() { io.Copy(remote, local); remote.Close(); close(done) }() + io.Copy(local, remote) + local.Close() + <-done +} +func (r *relay) pause() { + r.mu.Lock() + defer r.mu.Unlock() + r.paused = true + for c := range r.connections { + c.Close() + } +} +func (r *relay) resume() { r.mu.Lock(); r.paused = false; r.mu.Unlock() } +func (r *relay) close() { r.listener.Close(); r.pause(); r.wg.Wait() } +func TestRemoteReconnectSessionAndUnknown(t *testing.T) { + for _, resumed := range []bool{true, false} { + name := "SessionResumed" + if !resumed { + name = "SessionLostSubscriptionsRestored" + } + t.Run(name, func(t *testing.T) { testReconnect(t, resumed) }) + } +} +func TestRemoteSessionExpiry(t *testing.T) { + testReconnectExpiry(t, false, time.Second) +} +func testReconnect(t *testing.T, expectResumed bool) { testReconnectExpiry(t, expectResumed, 0) } +func testReconnectExpiry(t *testing.T, expectResumed bool, expiry time.Duration) { + cfg := remoteConfig(t, "test-backend", false) + if !expectResumed { + cfg.MQTT.SessionExpiry = expiry + } + target := strings.TrimPrefix(cfg.MQTT.URL, "tls://") + proxy := newRelay(t, target) + defer proxy.close() + // The generated server certificate explicitly includes 127.0.0.1 for health checks. + cfg.MQTT.URL = "tls://" + proxy.listener.Addr().String() + cfg.MQTT.ReconnectMin = 100 * time.Millisecond + cfg.MQTT.ReconnectMax = 300 * time.Millisecond + client := mqtt.New(cfg, store(cfg)) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := client.Start(ctx, []string{"acks", "telemetry"}, nil); err != nil { + t.Fatal(err) + } + defer client.Close() + b := backend.New(client) + workerCtx, stop := context.WithCancel(ctx) + workerDone := make(chan error, 1) + go func() { workerDone <- b.Serve(workerCtx) }() + defer func() { stop(); <-workerDone }() + simCfg := remoteConfig(t, "sim-001", false) + simCfg.Simulator.AckDelay = 500 * time.Millisecond + simClient := mqtt.New(simCfg, store(simCfg)) + sim := simulator.New(simClient) + simCtx, simStop := context.WithCancel(ctx) + simDone := make(chan error, 1) + go func() { simDone <- sim.Run(simCtx) }() + defer func() { simStop(); <-simDone }() + wait(t, simClient.Ready) + cmdDone := make(chan error, 1) + go func() { + _, err := b.Command(ctx, backend.NewCommand("sim-001", "ping", backend.ID(), 5*time.Second)) + cmdDone <- err + }() + wait(t, func() bool { _, counts := client.Events.Snapshot(); return counts[events.CommandAccepted] > 0 }) + proxy.pause() + wait(t, func() bool { return !client.Ready() }) + select { + case err := <-cmdDone: + if err == nil || !strings.Contains(err.Error(), "unknown") { + t.Fatal("connection loss did not produce unknown outcome") + } + case <-time.After(3 * time.Second): + t.Fatal("pending request did not stop on connection loss") + } + if expiry > 0 { + time.Sleep(expiry + 500*time.Millisecond) + } + proxy.resume() + wait(t, client.Ready) + history, _ := client.Events.Snapshot() + resumed := false + for _, e := range history { + if e.Kind == events.ConnectionUp && strings.Contains(e.Outcome, "session_present=true") { + resumed = true + } + } + if resumed != expectResumed { + t.Fatal("unexpected broker session present result") + } + ack, err := b.Command(ctx, backend.NewCommand("sim-001", "get_version", backend.ID(), 5*time.Second)) + if err != nil || ack == nil || ack.Result.Version == "" { + t.Fatal("command did not recover after reconnect") + } + t.Log("actual connection loss -> unknown, TLS reconnect, session-dependent subscription restoration and command recovery: PASS") +} diff --git a/internal/integration/reliability_test.go b/internal/integration/reliability_test.go new file mode 100644 index 0000000..8c9c151 --- /dev/null +++ b/internal/integration/reliability_test.go @@ -0,0 +1,117 @@ +package integration + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/eclipse/paho.golang/paho" + "robot-market/test-mqtt-5/internal/backend" + "robot-market/test-mqtt-5/internal/contracts" + "robot-market/test-mqtt-5/internal/events" + "robot-market/test-mqtt-5/internal/mqtt" +) + +func TestRemoteBrokerQueuedMessageExpiry(t *testing.T) { + deviceCfg := remoteConfig(t, "sim-001", false) + deviceCfg.MQTT.ClientID = "rm5-expiry-" + backend.ID()[:12] + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + device := mqtt.New(deviceCfg, store(deviceCfg)) + if err := device.Start(ctx, []string{"commands"}, nil); err != nil { + t.Fatal(err) + } + if err := device.Close(); err != nil { + t.Fatal(err) + } + serverCfg := remoteConfig(t, "test-backend", false) + server := mqtt.New(serverCfg, store(serverCfg)) + if err := server.Start(ctx, []string{"acks"}, nil); err != nil { + t.Fatal(err) + } + defer server.Close() + // Application deadline remains valid; the broker must expire this queued packet. + p := mqtt.Packet(mqtt.Topic(serverCfg, "commands"), 1, false, backend.NewCommand("sim-001", "ping", backend.ID(), 15*time.Second)) + p.Properties.MessageExpiry = paho.Uint32(1) + if err := server.Publish(ctx, p); err != nil { + t.Fatal(err) + } + time.Sleep(2 * time.Second) + deviceCfg.MQTT.CleanStart = false + device = mqtt.New(deviceCfg, store(deviceCfg)) + if err := device.Start(ctx, []string{"commands"}, nil); err != nil { + t.Fatal(err) + } + defer device.Close() + history, _ := device.Events.Snapshot() + resumed := false + for _, e := range history { + if e.Kind == events.ConnectionUp && strings.Contains(e.Outcome, "session_present=true") { + resumed = true + } + } + if !resumed { + t.Fatal("offline subscriber session was lost; expiry test inconclusive") + } + select { + case <-device.Incoming: + t.Fatal("broker delivered expired queued command") + case <-time.After(300 * time.Millisecond): + } + fresh := mqtt.Packet(mqtt.Topic(serverCfg, "commands"), 1, false, backend.NewCommand("sim-001", "ping", backend.ID(), 15*time.Second)) + if err := server.Publish(ctx, fresh); err != nil { + t.Fatal(err) + } + select { + case got := <-device.Incoming: + if string(got.Payload) != string(fresh.Payload) { + t.Fatal("wrong queued packet") + } + case <-time.After(3 * time.Second): + t.Fatal("fresh control command missing") + } + t.Log("broker expiry distinct from valid application deadline, persistent offline queue, fresh control delivery: PASS") +} + +func TestRemoteWillAfterAbruptDisconnect(t *testing.T) { + observerCfg := remoteConfig(t, "test-backend", false) + observer := mqtt.New(observerCfg, store(observerCfg)) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := observer.Start(ctx, []string{"status"}, nil); err != nil { + t.Fatal(err) + } + defer observer.Close() + cfg := remoteConfig(t, "sim-001", false) + proxy := newRelay(t, strings.TrimPrefix(cfg.MQTT.URL, "tls://")) + defer proxy.close() + cfg.MQTT.URL = "tls://" + proxy.listener.Addr().String() + cfg.MQTT.ReconnectMin = 100 * time.Millisecond + cfg.MQTT.ReconnectMax = 200 * time.Millisecond + boot := "will-" + backend.ID()[:12] + status := contracts.Status{Envelope: contracts.Envelope{SchemaVersion: 1, DeviceID: "sim-001"}, BootID: boot, State: "offline", WillConfiguredAt: backend.Timestamp(time.Now())} + packet := mqtt.Packet(mqtt.Topic(cfg, "status"), 1, false, status) + client := mqtt.New(cfg, store(cfg)) + if err := client.Start(ctx, []string{"commands"}, &paho.WillMessage{Topic: packet.Topic, Payload: packet.Payload, QoS: 1, Retain: false}); err != nil { + t.Fatal(err) + } + defer client.Close() + proxy.pause() + deadline := time.NewTimer(5 * time.Second) + defer deadline.Stop() + for { + select { + case p := <-observer.Incoming: + if strings.Contains(string(p.Payload), boot) { + if !strings.Contains(string(p.Payload), "will_configured_at") { + t.Fatal("Will invents disconnect timestamp") + } + t.Log("broker emits non-retained Will after actual abrupt TLS connection loss: PASS") + return + } + case <-deadline.C: + t.Fatal("Will not observed after abrupt connection loss") + } + } +} diff --git a/internal/mqtt/client.go b/internal/mqtt/client.go new file mode 100644 index 0000000..f20d606 --- /dev/null +++ b/internal/mqtt/client.go @@ -0,0 +1,461 @@ +// Package mqtt provides a bounded MQTT 5 transport without an application outbox. +package mqtt + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "math/rand/v2" + "net/url" + "os" + "strings" + "sync" + "sync/atomic" + "time" + + "robot-market/test-mqtt-5/internal/config" + "robot-market/test-mqtt-5/internal/contracts" + "robot-market/test-mqtt-5/internal/events" + + "github.com/eclipse/paho.golang/autopaho" + "github.com/eclipse/paho.golang/paho" +) + +var ErrNotReady = errors.New("MQTT subscriptions are not ready") +var ErrUnknown = errors.New("publication outcome unknown; no automatic command retry") + +type Client struct { + Config config.Config + Events *events.Store + Incoming chan *paho.Publish + Down chan struct{} + cm *autopaho.ConnectionManager + cancel context.CancelFunc + ready atomic.Bool + dropped atomic.Uint64 + invalid atomic.Uint64 + stateMu sync.Mutex + generation uint64 + state connection + changed chan struct{} + firstReady chan struct{} + firstOnce sync.Once + fatal chan error + workerDone chan struct{} +} +type connection struct { + cm *autopaho.ConnectionManager + ack *paho.Connack + generation uint64 +} + +func Topic(c config.Config, kind string) string { + return c.Topics.Namespace + "/" + c.Topics.DeviceID + "/" + kind +} + +// ObservedDevices is an explicit list: broker ACLs can authorize each exact topic. +func ObservedDevices(c config.Config) []string { + devices := []string{c.Topics.DeviceID} + seen := map[string]bool{c.Topics.DeviceID: true} + if c.Topics.ObserveDevices != "" { + for _, device := range strings.Split(c.Topics.ObserveDevices, ",") { + device = strings.TrimSpace(device) + if !seen[device] { + devices = append(devices, device) + seen[device] = true + } + } + } + return devices +} +func InNamespace(c config.Config, topic string) bool { + return topic == c.Topics.Namespace || strings.HasPrefix(topic, c.Topics.Namespace+"/") +} +func Identity(c config.Config, topic string) (device, kind string) { + if c.Topics.ObserveAll && InNamespace(c, topic) { + parts := strings.Split(strings.TrimPrefix(topic, c.Topics.Namespace+"/"), "/") + if len(parts) >= 2 { + return parts[0], strings.Join(parts[1:], "/") + } + return "", "" + } + for _, device := range ObservedDevices(c) { + for _, kind := range []string{"telemetry", "status", "commands", "acks"} { + if topic == c.Topics.Namespace+"/"+device+"/"+kind { + return device, kind + } + } + } + return "", "" +} +func Kind(c config.Config, topic string) string { + _, kind := Identity(c, topic) + return kind +} +func Backoff(min, max time.Duration) func(int) time.Duration { + return func(attempt int) time.Duration { + upper := min + for i := 1; i < attempt && upper < max; i++ { + if upper > max/2 { + upper = max + } else { + upper *= 2 + } + } + if upper > max { + upper = max + } + if upper <= min { + return min + } + return min + time.Duration(rand.Int64N(int64(upper-min)+1)) + } +} +func TLSConfig(c config.Config) (*tls.Config, error) { + tc := &tls.Config{MinVersion: tls.VersionTLS12} + if c.TLS.CAFile != "" { + data, err := os.ReadFile(c.TLS.CAFile) + if err != nil { + return nil, errors.New("cannot read CA file") + } + roots, err := x509.SystemCertPool() + if err != nil { + roots = x509.NewCertPool() + } + if !roots.AppendCertsFromPEM(data) { + return nil, errors.New("CA file contains no valid certificates") + } + tc.RootCAs = roots + } + if c.TLS.ClientCert != "" { + pair, err := tls.LoadX509KeyPair(c.TLS.ClientCert, c.TLS.ClientKey) + if err != nil { + return nil, errors.New("invalid client certificate/key pair") + } + tc.Certificates = []tls.Certificate{pair} + } + return tc, nil +} +func New(c config.Config, store *events.Store) *Client { + return &Client{Config: c, Events: store, Incoming: make(chan *paho.Publish, c.Limits.InboundQueue), Down: make(chan struct{}, 1), changed: make(chan struct{}, 1), firstReady: make(chan struct{}), fatal: make(chan error, 1), workerDone: make(chan struct{})} +} +func (c *Client) Ready() bool { return c.ready.Load() } +func (c *Client) Fatal() <-chan error { return c.fatal } +func (c *Client) change(cm *autopaho.ConnectionManager, ack *paho.Connack) { + c.ready.Store(false) + c.stateMu.Lock() + c.generation++ + c.state = connection{cm, ack, c.generation} + c.stateMu.Unlock() + select { + case c.changed <- struct{}{}: + default: + } + if ack == nil { + select { + case c.Down <- struct{}{}: + default: + } + } +} +func (c *Client) receive(r paho.PublishReceived) (bool, error) { + p := r.Packet + if p == nil { + return true, nil + } + admitted := Kind(c.Config, p.Topic) != "" + if c.Config.Topics.ObserveAll { + admitted = InNamespace(c.Config, p.Topic) + } + if !admitted || len(p.Payload) > c.Config.Limits.PayloadBytes || (!c.Config.Topics.ObserveAll && p.Properties != nil && (len(p.Properties.CorrelationData) > 64 || len(p.Properties.ResponseTopic) > 256)) { + c.invalid.Add(1) + return true, nil + } + cp := *p + cp.Payload = append([]byte(nil), p.Payload...) + if p.Properties != nil { + props := *p.Properties + props.CorrelationData = append([]byte(nil), props.CorrelationData...) + props.User = nil + if props.MessageExpiry != nil { + v := *props.MessageExpiry + props.MessageExpiry = &v + } + cp.Properties = &props + } + select { + case c.Incoming <- &cp: + default: + c.dropped.Add(1) + } + // Paho acknowledges receipt after callbacks. Receipt is not admission/execution. + return true, nil +} +func (c *Client) Start(ctx context.Context, kinds []string, will *paho.WillMessage) error { + if err := c.Config.Validate(); err != nil { + return err + } + tc, err := TLSConfig(c.Config) + if err != nil { + return err + } + password := []byte(c.Config.MQTT.Password) + if c.Config.MQTT.PasswordFile != "" { + password, err = os.ReadFile(c.Config.MQTT.PasswordFile) + if err != nil { + return errors.New("cannot read password file") + } + password = []byte(strings.TrimRight(string(password), "\r\n")) + } + u, _ := url.Parse(c.Config.MQTT.URL) + // Transport remains alive until explicit Close, so graceful offline publication can finish. + life, cancel := context.WithCancel(context.Background()) + c.cancel = cancel + go c.supervise(life, kinds) + ac := autopaho.ClientConfig{ + ServerUrls: []*url.URL{u}, TlsCfg: tc, KeepAlive: uint16(c.Config.MQTT.KeepAlive / time.Second), + CleanStartOnInitialConnection: c.Config.MQTT.CleanStart, SessionExpiryInterval: uint32(c.Config.MQTT.SessionExpiry / time.Second), + ReconnectBackoff: Backoff(c.Config.MQTT.ReconnectMin, c.Config.MQTT.ReconnectMax), ConnectTimeout: c.Config.Deadlines.Connect, + ConnectUsername: c.Config.MQTT.Username, ConnectPassword: password, WillMessage: will, + OnConnectionUp: func(cm *autopaho.ConnectionManager, a *paho.Connack) { c.change(cm, a) }, + OnConnectionDown: func() bool { c.change(nil, nil); return true }, + OnConnectError: func(error) { c.invalid.Add(1) }, + ConnectPacketBuilder: func(p *paho.Connect, _ *url.URL) (*paho.Connect, error) { + if p.Properties == nil { + p.Properties = &paho.ConnectProperties{} + } + size := uint32(32768) + receive := uint16(min(20, c.Config.Limits.InboundQueue)) + p.Properties.MaximumPacketSize = &size + p.Properties.ReceiveMaximum = &receive + return p, nil + }, + ClientConfig: paho.ClientConfig{ClientID: c.Config.MQTT.ClientID, PacketTimeout: c.Config.Deadlines.Operation, OnPublishReceived: []func(paho.PublishReceived) (bool, error){c.receive}}, + } + if will != nil { + ac.WillProperties = &paho.WillProperties{ContentType: "application/json", PayloadFormat: paho.Byte(1), WillDelayInterval: paho.Uint32(0)} + } + c.cm, err = autopaho.NewConnection(life, ac) + if err != nil { + cancel() + return errors.New("MQTT manager initialization failed") + } + wait, stop := context.WithTimeout(ctx, c.Config.Deadlines.Connect) + defer stop() + select { + case <-c.firstReady: + return nil + case err := <-c.fatal: + c.Close() + return err + case <-wait.Done(): + c.Close() + return errors.New("MQTT readiness deadline exceeded or interrupted") + } +} +func (c *Client) supervise(ctx context.Context, kinds []string) { + defer close(c.workerDone) + subscribed := false + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if n := c.dropped.Swap(0); n > 0 { + c.Events.Add(events.Event{Kind: events.EventGap, Direction: events.Internal, Outcome: fmt.Sprintf("inbound_dropped=%d; MQTT receipt was not application admission", n)}) + } + if n := c.invalid.Swap(0); n > 0 { + c.Events.Add(events.Event{Kind: events.ValidationFailed, Direction: events.Internal, Outcome: fmt.Sprintf("invalid_input_or_connection_attempts=%d", n)}) + } + case <-c.changed: + c.stateMu.Lock() + state := c.state + c.stateMu.Unlock() + if state.ack == nil { + c.Events.Add(events.Event{Kind: events.ConnectionDown, Direction: events.Internal, Outcome: "disconnected; pending execution unknown"}) + continue + } + c.Events.Add(events.Event{Kind: events.ConnectionUp, Direction: events.Internal, ClientID: c.Config.MQTT.ClientID, Outcome: fmt.Sprintf("session_present=%t; protocol=5", state.ack.SessionPresent)}) + if state.ack.Properties != nil && state.ack.Properties.MaximumQoS != nil && *state.ack.Properties.MaximumQoS < 1 { + c.fail("broker does not support required QoS 1") + continue + } + if !state.ack.SessionPresent || !subscribed { + subs := make([]paho.SubscribeOptions, 0, len(kinds)) + for _, device := range ObservedDevices(c.Config) { + for _, kind := range kinds { + subs = append(subs, paho.SubscribeOptions{Topic: c.Config.Topics.Namespace + "/" + device + "/" + kind, QoS: 1}) + } + } + if c.Config.Topics.ObserveAll { + subs = []paho.SubscribeOptions{{Topic: c.Config.Topics.Namespace + "/#", QoS: 1}} + } + op, stop := context.WithTimeout(ctx, c.Config.Deadlines.Operation) + ack, err := state.cm.Subscribe(op, &paho.Subscribe{Subscriptions: subs}) + stop() + c.stateMu.Lock() + current := c.generation == state.generation + c.stateMu.Unlock() + // A lost connection invalidates this SUBACK attempt. Let the + // newer connection restore readiness instead of failing it. + if !current { + continue + } + if err != nil || ack == nil || len(ack.Reasons) != len(subs) { + c.fail("subscription setup failed") + continue + } + valid := true + for _, reason := range ack.Reasons { + if reason >= 128 { + valid = false + } + } + if !valid { + c.fail("broker rejected required subscription") + continue + } + subscribed = true + } + c.stateMu.Lock() + current := c.generation == state.generation + if current { + c.ready.Store(true) + } + c.stateMu.Unlock() + if current { + c.Events.Add(events.Event{Kind: events.Kind("subscriptions_ready"), Direction: events.Internal, ClientID: c.Config.MQTT.ClientID, Outcome: c.SubscriptionSummary(kinds)}) + c.firstOnce.Do(func() { close(c.firstReady) }) + } + } + } +} +func (c *Client) SubscriptionSummary(kinds []string) string { + if c.Config.Topics.ObserveAll { + return "topic=" + c.Config.Topics.Namespace + "/#; raw observation; contract validation disabled" + } + return "devices=" + strings.Join(ObservedDevices(c.Config), ",") + "; categories=" + strings.Join(kinds, ",") +} +func (c *Client) fail(message string) { + c.ready.Store(false) + select { + case c.fatal <- errors.New(message): + default: + } +} +func (c *Client) Close() error { + ctx, stop := context.WithTimeout(context.Background(), c.Config.Deadlines.Shutdown) + defer stop() + return c.CloseContext(ctx) +} + +// CloseContext shares the caller's complete shutdown budget, including offline publication. +func (c *Client) CloseContext(ctx context.Context) error { + if c.cancel == nil { + return nil + } + c.change(nil, nil) + var err error + if c.cm != nil { + err = c.cm.Disconnect(ctx) + } + c.cancel() + select { + case <-c.workerDone: + case <-ctx.Done(): + return errors.New("MQTT shutdown deadline exceeded") + } + if err != nil { + return errors.New("MQTT shutdown failed") + } + return nil +} + +func (c *Client) Record(kind events.Kind, direction events.Direction, p *paho.Publish, request, outcome string) { + e := events.Event{Kind: kind, Direction: direction, ClientID: c.Config.MQTT.ClientID, DeviceID: c.Config.Topics.DeviceID, RequestID: request, Outcome: outcome} + if p != nil { + e.Topic = p.Topic + e.QoS = p.QoS + e.Retain = p.Retain + duplicate := p.Duplicate() + e.Duplicate = &duplicate + device, category := Identity(c.Config, p.Topic) + e.DeviceID = device + e.Payload = events.SafePreview(category, device, p.Payload, c.Config.Limits.PreviewBytes) + if c.Config.Logging.RawPayloads || c.Config.Topics.ObserveAll { + e.Payload = events.RawPreview(p.Payload, c.Config.Limits.PreviewBytes) + } + // Only native values belonging to our exact test contract are visible. + if p.Properties != nil { + if p.Properties.ResponseTopic == Topic(c.Config, "acks") || (c.Config.Topics.ObserveAll && len(p.Properties.ResponseTopic) <= 256) { + e.Properties.ResponseTopic = p.Properties.ResponseTopic + } + if len(p.Properties.CorrelationData) <= 64 && (c.Config.Topics.ObserveAll || (request != "" && string(p.Properties.CorrelationData) == request)) { + e.Properties.CorrelationData = append([]byte(nil), p.Properties.CorrelationData...) + } + e.Properties.MessageExpirySeconds = p.Properties.MessageExpiry + } + } + c.Events.Add(e) +} +func (c *Client) Publish(ctx context.Context, p *paho.Publish) error { + kind := Kind(c.Config, p.Topic) + if kind == "" || len(p.Payload) > c.Config.Limits.PayloadBytes { + return errors.New("publication outside test contract") + } + msg, err := contracts.Decode(kind, p.Payload, c.Config.Topics.DeviceID) + if err != nil { + return err + } + request := "" + switch v := msg.(type) { + case *contracts.Command: + request = v.RequestID + case *contracts.Ack: + request = v.RequestID + } + return c.publishPacket(ctx, p, request) +} + +// PublishRaw sends test receipt replies without requiring a device JSON contract. +func (c *Client) PublishRaw(ctx context.Context, p *paho.Publish) error { + if p == nil || !InNamespace(c.Config, p.Topic) || len(p.Payload) > c.Config.Limits.PayloadBytes { + return errors.New("receipt publication outside test namespace or size limit") + } + return c.publishPacket(ctx, p, "") +} +func (c *Client) publishPacket(ctx context.Context, p *paho.Publish, request string) error { + c.Record(events.PublishAttempt, events.Out, p, request, "") + if !c.Ready() { + return ErrNotReady + } + op, stop := context.WithTimeout(ctx, c.Config.Deadlines.Operation) + defer stop() + ack, err := c.cm.Publish(op, p) + if err != nil { + c.Record(events.Kind("transport_unknown"), events.Internal, p, request, "publication not confirmed; execution unknown") + return ErrUnknown + } + if ack != nil && ack.ReasonCode >= 128 { + c.Record(events.Kind("transport_rejected"), events.Internal, p, request, fmt.Sprintf("reason_code=%d", ack.ReasonCode)) + return errors.New("broker rejected publication") + } + outcome := "QoS0 write completed; no broker acknowledgement" + if p.QoS == 1 { + if ack == nil { + return ErrUnknown + } + outcome = fmt.Sprintf("reason_code=%d; device execution not proven", ack.ReasonCode) + } + c.Record(events.TransportAck, events.Internal, p, request, outcome) + return nil +} +func Packet(topic string, qos byte, retained bool, value any) *paho.Publish { + data, _ := json.Marshal(value) + return &paho.Publish{Topic: topic, QoS: qos, Retain: retained, Payload: data, Properties: &paho.PublishProperties{ContentType: "application/json", PayloadFormat: paho.Byte(1)}} +} diff --git a/internal/mqtt/client_test.go b/internal/mqtt/client_test.go new file mode 100644 index 0000000..f4d3b48 --- /dev/null +++ b/internal/mqtt/client_test.go @@ -0,0 +1,92 @@ +package mqtt + +import ( + "io" + "log/slog" + "testing" + "time" + + "github.com/eclipse/paho.golang/paho" + "robot-market/test-mqtt-5/internal/config" + "robot-market/test-mqtt-5/internal/events" +) + +func TestCopyAndOverflow(t *testing.T) { + cfg := config.Defaults() + cfg.Limits.InboundQueue = 1 + c := New(cfg, events.NewStore(4, slog.New(slog.NewTextHandler(io.Discard, nil)))) + p := &paho.Publish{Topic: Topic(cfg, "acks"), Payload: []byte("payload"), Properties: &paho.PublishProperties{CorrelationData: []byte("id"), MessageExpiry: paho.Uint32(5)}} + c.receive(paho.PublishReceived{Packet: p}) + p.Payload[0] = 'X' + p.Properties.CorrelationData[0] = 'X' + *p.Properties.MessageExpiry = 99 + c.receive(paho.PublishReceived{Packet: p}) + if c.dropped.Load() != 1 { + t.Fatal("overflow not counted") + } + got := <-c.Incoming + if string(got.Payload) != "payload" || string(got.Properties.CorrelationData) != "id" || *got.Properties.MessageExpiry != 5 { + t.Fatal("callback input was aliased") + } + p.Topic = "production/topic" + c.receive(paho.PublishReceived{Packet: p}) + if c.invalid.Load() != 1 { + t.Fatal("foreign topic not rejected") + } +} +func TestBoundedBackoff(t *testing.T) { + f := Backoff(time.Second, 30*time.Second) + for attempt := 0; attempt < 100; attempt++ { + for n := 0; n < 10; n++ { + d := f(attempt) + if d < time.Second || d > 30*time.Second { + t.Fatal("backoff outside bounds") + } + } + } +} +func TestTLSNeverSkipsVerification(t *testing.T) { + cfg := config.Defaults() + tc, err := TLSConfig(cfg) + if err != nil || tc.InsecureSkipVerify || tc.MinVersion == 0 { + t.Fatal("unsafe TLS") + } +} + +func TestObservedDeviceTopicIdentity(t *testing.T) { + cfg := config.Defaults() + cfg.Topics.ObserveDevices = "sim-001, esp32-lab-001,esp32-lab-001" + if len(ObservedDevices(cfg)) != 2 { + t.Fatal("duplicate subscriptions") + } + device, kind := Identity(cfg, "robot-market/test/v1/esp32-lab-001/telemetry") + if device != "esp32-lab-001" || kind != "telemetry" { + t.Fatal("device topic missing") + } + for _, topic := range []string{"robot-market/test/v1/other/telemetry", "production/esp32-lab-001/telemetry", "robot-market/test/v1/esp32-lab-001/unknown"} { + if _, kind := Identity(cfg, topic); kind != "" { + t.Fatal("unconfigured topic admitted") + } + } +} + +func TestRawObservationAdmitsNamespaceWithoutDeviceOrCategoryRestriction(t *testing.T) { + cfg := config.Defaults() + cfg.Topics.ObserveAll = true + c := New(cfg, events.NewStore(8, slog.New(slog.NewTextHandler(io.Discard, nil)))) + for _, topic := range []string{cfg.Topics.Namespace, cfg.Topics.Namespace + "/custom", cfg.Topics.Namespace + "/new-device/custom-topic"} { + c.receive(paho.PublishReceived{Packet: &paho.Publish{Topic: topic, Payload: []byte("not JSON")}}) + select { + case got := <-c.Incoming: + if got.Topic != topic { + t.Fatal("topic changed") + } + default: + t.Fatal("raw topic filtered") + } + } + c.receive(paho.PublishReceived{Packet: &paho.Publish{Topic: cfg.Topics.Namespace + "-other/device/custom"}}) + if c.invalid.Load() != 1 { + t.Fatal("namespace boundary lost") + } +} diff --git a/internal/simulator/simulator.go b/internal/simulator/simulator.go new file mode 100644 index 0000000..5ef2293 --- /dev/null +++ b/internal/simulator/simulator.go @@ -0,0 +1,193 @@ +// Package simulator executes only harmless ping/version commands in a bounded worker. +package simulator + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "time" + + "github.com/eclipse/paho.golang/paho" + "robot-market/test-mqtt-5/internal/backend" + "robot-market/test-mqtt-5/internal/contracts" + "robot-market/test-mqtt-5/internal/events" + "robot-market/test-mqtt-5/internal/mqtt" +) + +type entry struct { + fingerprint [32]byte + command contracts.Command + correlation []byte + ack contracts.Ack + due, forget time.Time + complete bool +} +type Simulator struct { + Client *mqtt.Client + BootID string + cache map[string]*entry + Executions uint64 + sequence uint64 +} + +func New(client *mqtt.Client) *Simulator { + return &Simulator{Client: client, BootID: backend.ID(), cache: make(map[string]*entry)} +} +func (s *Simulator) envelope() contracts.Envelope { + return contracts.Envelope{SchemaVersion: 1, DeviceID: s.Client.Config.Topics.DeviceID} +} +func (s *Simulator) status(state string, will bool) contracts.Status { + status := contracts.Status{Envelope: s.envelope(), BootID: s.BootID, State: state, Implementation: "go-simulator", ReportedProtocol: "5"} + if will { + status.WillConfiguredAt = backend.Timestamp(time.Now()) + } else { + status.Timestamp = backend.Timestamp(time.Now()) + } + return status +} +func (s *Simulator) Will() *paho.WillMessage { + p := mqtt.Packet(mqtt.Topic(s.Client.Config, "status"), 1, true, s.status("offline", true)) + return &paho.WillMessage{Topic: p.Topic, Payload: p.Payload, QoS: 1, Retain: true} +} +func (s *Simulator) ack(id, stage, code string) contracts.Ack { + ok := stage == "accepted" || stage == "completed" + ack := contracts.Ack{Envelope: s.envelope(), RequestID: id, Timestamp: backend.Timestamp(time.Now()), Stage: stage, OK: &ok} + if !ok { + ack.Error = &contracts.AckError{Code: code, Message: "test simulator rejected the request"} + } + return ack +} +func (s *Simulator) sendAck(ctx context.Context, ack contracts.Ack, correlation []byte) { + p := mqtt.Packet(mqtt.Topic(s.Client.Config, "acks"), 1, false, ack) + p.Properties.CorrelationData = append([]byte(nil), correlation...) + if err := s.Client.Publish(ctx, p); err != nil { + s.Client.Record(events.Kind("ack_not_confirmed"), events.Internal, p, ack.RequestID, "cached outcome; replay only on duplicate request") + } +} +func (s *Simulator) purge(now time.Time) { + for id, e := range s.cache { + if e.complete && !now.Before(e.forget) { + delete(s.cache, id) + } + } +} +func (s *Simulator) Handle(ctx context.Context, p *paho.Publish) { + if ctx.Err() != nil { + return + } + value, err := contracts.Decode("commands", p.Payload, s.Client.Config.Topics.DeviceID) + if err != nil || p.Topic != mqtt.Topic(s.Client.Config, "commands") { + s.Client.Record(events.ValidationFailed, events.In, p, "", "invalid command contract") + return + } + cmd := *value.(*contracts.Command) + s.Client.Record(events.MessageReceived, events.In, p, cmd.RequestID, "") + if p.Retain || p.QoS != 1 || p.Properties == nil || p.Properties.ResponseTopic != mqtt.Topic(s.Client.Config, "acks") || (len(p.Properties.CorrelationData) == 0 || len(p.Properties.CorrelationData) > 64) || p.Properties.MessageExpiry == nil || *p.Properties.MessageExpiry == 0 || *p.Properties.MessageExpiry > 60 { + s.Client.Record(events.ValidationFailed, events.In, p, cmd.RequestID, "requires non-retained QoS1 and exact native command properties") + return + } + now := time.Now() + s.purge(now) + data, _ := json.Marshal(cmd) + fingerprint := sha256.Sum256(append(data, p.Properties.CorrelationData...)) + if previous, ok := s.cache[cmd.RequestID]; ok { + if previous.fingerprint != fingerprint { + s.sendAck(ctx, s.ack(cmd.RequestID, "rejected", "conflicting_request"), p.Properties.CorrelationData) + return + } + s.Client.Record(events.DuplicateRequest, events.In, p, cmd.RequestID, "cached ACK replay; no repeated execution") + s.sendAck(ctx, previous.ack, previous.correlation) + return + } + if err := cmd.CheckDeadline(now); err != nil { + s.sendAck(ctx, s.ack(cmd.RequestID, "rejected", "deadline_invalid"), p.Properties.CorrelationData) + return + } + if len(s.cache) >= s.Client.Config.Limits.DedupEntries { + s.sendAck(ctx, s.ack(cmd.RequestID, "rejected", "dedup_capacity"), p.Properties.CorrelationData) + return + } + accepted := s.ack(cmd.RequestID, "accepted", "") + s.cache[cmd.RequestID] = &entry{fingerprint: fingerprint, command: cmd, correlation: append([]byte(nil), p.Properties.CorrelationData...), ack: accepted, due: now.Add(s.Client.Config.Simulator.AckDelay), forget: now.Add(s.Client.Config.Limits.DedupRetention)} + s.Client.Record(events.CommandAccepted, events.Internal, p, cmd.RequestID, "admitted to bounded simulator worker") + s.sendAck(ctx, accepted, p.Properties.CorrelationData) +} +func (s *Simulator) CompleteDue(ctx context.Context, now time.Time) { + if ctx.Err() != nil { + return + } + s.purge(now) + for id, e := range s.cache { + if e.complete || now.Before(e.due) { + continue + } + if err := e.command.CheckDeadline(now); err != nil { + e.ack = s.ack(id, "failed", "deadline_elapsed") + } else { + s.Executions++ + e.ack = s.ack(id, "completed", "") + if e.command.Command == "ping" { + yes := true + e.ack.Result = &contracts.Result{Pong: &yes} + } else { + e.ack.Result = &contracts.Result{Version: "test-sim-1.0.0"} + } + } + e.complete = true + e.forget = now.Add(s.Client.Config.Limits.DedupRetention) + s.Client.Events.Add(events.Event{Kind: events.Kind("simulator_execution"), Direction: events.Internal, DeviceID: s.Client.Config.Topics.DeviceID, RequestID: id, Outcome: e.ack.Stage}) + s.sendAck(ctx, e.ack, e.correlation) + } +} +func (s *Simulator) Run(ctx context.Context) (runErr error) { + if err := s.Client.Start(ctx, []string{"commands"}, s.Will()); err != nil { + return err + } + defer func() { + shutdown, stop := context.WithTimeout(context.Background(), s.Client.Config.Deadlines.Shutdown) + defer stop() + if err := s.Client.Publish(shutdown, mqtt.Packet(mqtt.Topic(s.Client.Config, "status"), 1, true, s.status("offline", false))); err != nil { + s.Client.Record(events.Kind("offline_not_confirmed"), events.Internal, nil, "", "graceful offline status not confirmed") + } + if err := s.Client.CloseContext(shutdown); err != nil && runErr == nil { + runErr = err + } + }() + tick := time.NewTicker(50 * time.Millisecond) + defer tick.Stop() + telemetry := time.NewTicker(s.Client.Config.Simulator.TelemetryInterval) + defer telemetry.Stop() + online := false + for { + select { + case <-ctx.Done(): + return nil + case err := <-s.Client.Fatal(): + return err + case <-s.Client.Down: + online = false + case p := <-s.Client.Incoming: + s.Handle(ctx, p) + case now := <-tick.C: + if !s.Client.Ready() { + online = false + } + if !online && s.Client.Ready() { + if err := s.Client.Publish(ctx, mqtt.Packet(mqtt.Topic(s.Client.Config, "status"), 1, true, s.status("online", false))); err != nil { + return errors.New("online status not confirmed") + } + online = true + } + s.CompleteDue(ctx, now) + case <-telemetry.C: + if !s.Client.Ready() { + continue + } + s.sequence++ + seq := s.sequence + message := contracts.Telemetry{Envelope: s.envelope(), MessageID: backend.ID(), BootID: s.BootID, Sequence: &seq, Timestamp: backend.Timestamp(time.Now()), Readings: map[string]float64{"temperature_c": 22.5, "executions": float64(s.Executions)}} + _ = s.Client.Publish(ctx, mqtt.Packet(mqtt.Topic(s.Client.Config, "telemetry"), byte(s.Client.Config.MQTT.QoS), false, message)) + } + } +} diff --git a/internal/simulator/simulator_test.go b/internal/simulator/simulator_test.go new file mode 100644 index 0000000..2e0bc65 --- /dev/null +++ b/internal/simulator/simulator_test.go @@ -0,0 +1,103 @@ +package simulator + +import ( + "context" + "io" + "log/slog" + "testing" + "time" + + "github.com/eclipse/paho.golang/paho" + "robot-market/test-mqtt-5/internal/backend" + "robot-market/test-mqtt-5/internal/config" + "robot-market/test-mqtt-5/internal/events" + "robot-market/test-mqtt-5/internal/mqtt" +) + +func testSimulator() *Simulator { + cfg := config.Defaults() + return New(mqtt.New(cfg, events.NewStore(64, slog.New(slog.NewTextHandler(io.Discard, nil))))) +} +func commandPacket(s *Simulator, cmdName, id string) *paho.Publish { + cmd := backend.NewCommand("sim-001", cmdName, id, 15*time.Second) + p := mqtt.Packet(mqtt.Topic(s.Client.Config, "commands"), 1, false, cmd) + p.Properties.ResponseTopic = mqtt.Topic(s.Client.Config, "acks") + p.Properties.CorrelationData = []byte(id) + p.Properties.MessageExpiry = paho.Uint32(15) + return p +} +func TestDuplicateAndConflict(t *testing.T) { + s := testSimulator() + ctx := context.Background() + id := backend.ID() + p := commandPacket(s, "ping", id) + s.Handle(ctx, p) + s.Handle(ctx, p) + if len(s.cache) != 1 { + t.Fatal("duplicate not bounded") + } + s.CompleteDue(ctx, time.Now()) + s.Handle(ctx, p) + s.CompleteDue(ctx, time.Now()) + if s.Executions != 1 || !s.cache[id].complete { + t.Fatal("duplicate executed again") + } + conflict := commandPacket(s, "get_version", id) + s.Handle(ctx, conflict) + s.CompleteDue(ctx, time.Now()) + if s.Executions != 1 || s.cache[id].command.Command != "ping" { + t.Fatal("conflicting reuse executed") + } +} +func TestInvalidMetadataAndCapacity(t *testing.T) { + for _, bad := range []string{"retained", "topic", "correlation", "expiry", "qos"} { + t.Run(bad, func(t *testing.T) { + s := testSimulator() + p := commandPacket(s, "ping", backend.ID()) + switch bad { + case "retained": + p.Retain = true + case "topic": + p.Properties.ResponseTopic = "other/acks" + case "correlation": + p.Properties.CorrelationData = nil + case "expiry": + p.Properties.MessageExpiry = nil + case "qos": + p.QoS = 0 + } + s.Handle(context.Background(), p) + if len(s.cache) != 0 { + t.Fatal("invalid command admitted") + } + }) + } + s := testSimulator() + s.Client.Config.Limits.DedupEntries = 1 + s.Client.Config.Simulator.AckDelay = time.Second + s.Handle(context.Background(), commandPacket(s, "ping", backend.ID())) + s.Handle(context.Background(), commandPacket(s, "ping", backend.ID())) + if len(s.cache) != 1 { + t.Fatal("cache capacity ignored") + } + s.CompleteDue(context.Background(), time.Now().Add(time.Minute)) + if s.Executions != 0 { + t.Fatal("expired command executed") + } + s.purge(time.Now().Add(time.Hour)) + if len(s.cache) != 0 { + t.Fatal("completed cache not purged") + } +} + +func TestCancellationStopsAdmissionAndExecution(t *testing.T) { + s := testSimulator() + s.Handle(context.Background(), commandPacket(s, "ping", backend.ID())) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + s.Handle(ctx, commandPacket(s, "ping", backend.ID())) + s.CompleteDue(ctx, time.Now().Add(time.Second)) + if len(s.cache) != 1 || s.Executions != 0 { + t.Fatal("work admitted/executed after cancellation") + } +} diff --git a/internal/web/assets/app.js b/internal/web/assets/app.js new file mode 100644 index 0000000..e9689f3 --- /dev/null +++ b/internal/web/assets/app.js @@ -0,0 +1,14 @@ +(()=>{ + const q=(selector)=>document.querySelector(selector),limit=1000; + const timeline=q('#timeline'),notice=q('#notice'),streamLabel=q('#stream'),connection=q('#connection'),filters=q('#filters'); + const items=[];let paused=false,overflow=0,lastId=0,source=null; + function filtered(event){const values=new FormData(filters);return (!values.get('direction')||String(event.direction||'').toLowerCase().includes(String(values.get('direction')).trim().toLowerCase()))&&(!values.get('topic')||String(event.topic||'').toLowerCase().includes(String(values.get('topic')).trim().toLowerCase()))&&(!values.get('device')||String(event.device_id||'').toLowerCase().includes(String(values.get('device')).trim().toLowerCase()))&&(!values.get('request')||String(event.request_id||'').toLowerCase().includes(String(values.get('request')).trim().toLowerCase()))} + function node(tag,className,text){const el=document.createElement(tag);if(className)el.className=className;if(text!==undefined)el.textContent=text;return el} + function renderEvent(e){const card=node('article','event '+e.direction);const head=node('div','event-head');head.append(node('span','kind',e.kind));head.append(node('time','time',new Date(e.observed_at).toLocaleTimeString()));const details=[e.direction,e.topic,e.device_id&&('device '+e.device_id),e.request_id&&('request '+e.request_id),'QoS '+e.qos,e.retain?'retained':'non-retained',e.outcome].filter(Boolean);head.append(node('span','meta',details.join(' · ')));card.append(head);const p=e.payload||{};if(p.text||p.omitted){card.append(node('pre','',p.omitted?('Payload omitted · '+p.bytes+' bytes'):p.text+(p.truncated?' … [preview truncated]':'')))}const props=e.properties||{};const values=[];if(props.response_topic)values.push('Response Topic '+props.response_topic);if(props.correlation_data)values.push('Correlation Data '+props.correlation_data);if(props.message_expiry_seconds!==undefined)values.push('Message Expiry '+props.message_expiry_seconds+'s');if(values.length)card.append(node('div','meta',values.join(' · ')));if(e.latency_ms!==undefined)card.append(node('div','meta','This process measured '+e.latency_ms+' ms'));return card} + function redraw(){timeline.replaceChildren();let shown=0;for(const e of items){if(!filtered(e))continue;timeline.append(renderEvent(e));shown++}if(!shown)timeline.append(node('div','empty',items.length?'No events match these filters.':'Waiting for events. Start the device simulator or ESP32 to see input and output.'));notice.textContent=overflow?'Event gap: '+overflow+' earlier event(s) are outside this page’s bounded view.':''} + function add(e){lastId=Math.max(lastId,e.id||0);if(paused){overflow++;return}items.push(e);if(items.length>limit)items.splice(0,items.length-limit);redraw()} + function compatibility(rows){const tbody=q('#compatibility');tbody.replaceChildren();for(const row of rows||[]){const tr=document.createElement('tr');tr.append(node('td','',row.feature));const td=node('td');td.append(node('span','status '+row.status,row.status));tr.append(td,node('td','',row.evidence));tbody.append(tr)}} + function stats(data){connection.textContent=data.connection||'starting';connection.className='badge '+(data.connection||'starting');const box=q('#stats');box.replaceChildren();const all=[['Retained events',data.events?.length||0],['Last event ID',data.last_event_id||0],['Received',data.counts?.message_received||0],['Contract errors',data.counts?.validation_failed||0],['Published',data.counts?.publish_attempt||0],['Transport ACK',data.counts?.transport_ack||0],['Receipt ACKs',data.counts?.receipt_ack_sent||0],['ACK failures',data.counts?.receipt_ack_failed||0],['ACK queue drops',data.counts?.receipt_ack_dropped||0],['Accepted',data.counts?.command_accepted||0],['Completed',data.counts?.command_completed||0],['Timeouts',data.counts?.command_timeout||0],['Event gaps',data.counts?.event_gap||0]];for(const [label,value]of all){const card=node('div','stat');card.append(node('b','',String(value)),node('span','',label));box.append(card)}compatibility(data.compatibility)} + async function connect(){streamLabel.textContent='connecting';try{const response=await fetch('/api/snapshot',{cache:'no-store'});if(!response.ok)throw Error();const data=await response.json();stats(data);if(!source){if(data.gap){lastId=Number(data.gap.reset_id)||lastId;overflow+=Number(data.gap.gap)||1}for(const event of data.events||[])add(event);source=new EventSource('/api/events?after='+encodeURIComponent(lastId));source.addEventListener('mqtt-event',e=>{try{add(JSON.parse(e.data).event)}catch{notice.textContent='Received an unreadable event.'}});source.addEventListener('gap',e=>{let value={};try{value=JSON.parse(e.data)}catch{}lastId=Number(value.reset_id)||lastId;overflow+=Number(value.gap)||1;streamLabel.textContent='resynced after event gap';redraw()});source.onopen=()=>{streamLabel.textContent='live'};source.onerror=()=>{streamLabel.textContent='reconnecting'}}}catch{connection.textContent='viewer unavailable';connection.className='badge disconnected';streamLabel.textContent='disconnected';setTimeout(connect,2000)}} + q('#pause').addEventListener('click',()=>{paused=!paused;q('#pause').textContent=paused?'Resume view':'Pause view';if(!paused){overflow=0;redraw()}});q('#clear').addEventListener('click',()=>{items.length=0;redraw()});filters.addEventListener('input',redraw);filters.addEventListener('reset',()=>setTimeout(redraw,0));connect();setInterval(async()=>{try{const r=await fetch('/api/snapshot',{cache:'no-store'});if(r.ok)stats(await r.json())}catch{}},3000) +})(); diff --git a/internal/web/assets/style.css b/internal/web/assets/style.css new file mode 100644 index 0000000..5c065f0 --- /dev/null +++ b/internal/web/assets/style.css @@ -0,0 +1 @@ +:root{color-scheme:dark;--bg:#10151c;--panel:#19232d;--edge:#2b3946;--text:#edf4f7;--muted:#9badb8;--blue:#77c8e9;--green:#66d6a2;--orange:#ffca77;--red:#ff9292;font:15px/1.5 ui-sans-serif,system-ui,sans-serif}*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 90% -10%,#244154 0,transparent 42%),var(--bg);color:var(--text)}header,main{width:min(1360px,calc(100% - 40px));margin:auto}header{padding:42px 0 28px;display:flex;align-items:center;justify-content:space-between;gap:18px}.eyebrow{letter-spacing:.16em;color:var(--blue);font-size:.75rem;font-weight:700;margin:0 0 8px}h1{font-size:clamp(2rem,5vw,3.4rem);line-height:1.05;margin:0}h2{margin:0 0 10px;font-size:1.25rem}.sub,.hint{color:var(--muted);margin:8px 0}.badge,.stream{border:1px solid var(--edge);padding:7px 12px;border-radius:999px;color:var(--muted);white-space:nowrap}.badge.connected{color:var(--green);border-color:#34785c}.badge.disconnected{color:var(--red);border-color:#76414b}.badge.starting{color:var(--orange)}main{padding-bottom:60px}.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(135px,1fr));gap:10px;margin-bottom:16px}.stat,.compat,.events{border:1px solid var(--edge);background:linear-gradient(145deg,#1b2731,#151d26);border-radius:14px}.stat{padding:14px}.stat b{display:block;font-size:1.55rem}.stat span{font-size:.82rem;color:var(--muted)}.compat,.events{padding:20px;margin-top:14px}.table-scroll{overflow:auto}table{width:100%;border-collapse:collapse;text-align:left}th,td{padding:11px;border-bottom:1px solid var(--edge);vertical-align:top}th{color:var(--muted);font-size:.8rem;font-weight:600}.status{display:inline-block;border:1px solid var(--edge);padding:2px 9px;border-radius:999px;color:var(--orange);font-size:.8rem}.status.passed{color:var(--green);border-color:#34785c}.status.failed{color:var(--red);border-color:#76414b}.status.blocked,.status.unsupported{color:var(--muted)}.section-heading{display:flex;align-items:center;justify-content:space-between;gap:16px}.actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}button,input{font:inherit;color:var(--text);background:#101820;border:1px solid var(--edge);border-radius:8px;padding:8px 10px}button{cursor:pointer}button:hover{border-color:var(--blue)}button:focus-visible,input:focus-visible{outline:2px solid var(--blue);outline-offset:2px}.stream{font-size:.8rem}.filters{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));align-items:end;gap:10px;margin:18px 0}.filters label{display:grid;gap:5px;color:var(--muted);font-size:.82rem}.notice{min-height:1.5em;color:var(--orange)}.timeline{display:grid;gap:9px}.event{border:1px solid var(--edge);border-left:3px solid var(--blue);border-radius:9px;background:#111921;padding:12px 14px;min-width:0}.event.out{border-left-color:#b79cff}.event.internal{border-left-color:var(--orange)}.event-head{display:flex;gap:9px;align-items:center;flex-wrap:wrap}.kind{font-weight:700}.time,.meta{color:var(--muted);font-size:.82rem}.event pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:160px;overflow:auto;margin:9px 0 0;padding:10px;background:#0b1117;border-radius:7px;color:#c9e5ef;font:12px/1.5 ui-monospace,monospace}.empty{border:1px dashed var(--edge);padding:28px;text-align:center;border-radius:10px;color:var(--muted)}@media(max-width:720px){header{align-items:flex-start;padding-top:26px;flex-direction:column}.section-heading{align-items:flex-start;flex-direction:column}.compat,.events{padding:14px}header,main{width:min(100% - 24px,1360px)}} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..c1d475d --- /dev/null +++ b/internal/web/server.go @@ -0,0 +1,320 @@ +// Package web serves an optional loopback-only, read-only event inspector. +package web + +import ( + "context" + "embed" + "encoding/json" + "errors" + "fmt" + "io/fs" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "robot-market/test-mqtt-5/internal/config" + "robot-market/test-mqtt-5/internal/events" +) + +const maxReplay = 500 + +//go:embed assets +var assets embed.FS + +type Compatibility struct { + Feature string `json:"feature"` + Status string `json:"status"` + Evidence string `json:"evidence"` +} +type Snapshot struct { + Events []events.Event `json:"events"` + Counts map[events.Kind]uint64 `json:"counts"` + Gap *events.Notice `json:"gap,omitempty"` + Connection string `json:"connection"` + LastEventID uint64 `json:"last_event_id"` + Compatibility []Compatibility `json:"compatibility"` +} +type Viewer struct { + cfg config.Web + store *events.Store + shutdown time.Duration + handler http.Handler + server *http.Server + listener net.Listener + done chan error + slots chan struct{} + active atomic.Int32 + once sync.Once +} + +func New(cfg config.Web, store *events.Store, shutdown time.Duration) *Viewer { + v := &Viewer{cfg: cfg, store: store, shutdown: shutdown, done: make(chan error, 1), slots: make(chan struct{}, cfg.MaxViewers)} + mux := http.NewServeMux() + mux.HandleFunc("GET /", v.index) + sub, _ := fs.Sub(assets, "assets") + mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(sub)))) + mux.HandleFunc("GET /api/snapshot", v.snapshot) + mux.HandleFunc("GET /api/events", v.stream) + v.handler = v.guard(mux) + v.server = &http.Server{Handler: v.handler, ReadHeaderTimeout: 3 * time.Second, IdleTimeout: 20 * time.Second, MaxHeaderBytes: 8 << 10} + return v +} +func (v *Viewer) Address() string { + if v.listener == nil { + return v.cfg.Address + } + return v.listener.Addr().String() +} +func (v *Viewer) Handler() http.Handler { return v.handler } +func (v *Viewer) Start() error { + listener, err := net.Listen("tcp", v.cfg.Address) + if err != nil { + return errors.New("viewer could not bind configured loopback address") + } + v.listener = listener + go func() { + err := v.server.Serve(listener) + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + v.done <- err + }() + return nil +} +func (v *Viewer) Close(ctx context.Context) error { + var result error + v.once.Do(func() { + if v.listener == nil { + return + } + result = v.server.Shutdown(ctx) + if result != nil { + _ = v.server.Close() + } + select { + case err := <-v.done: + if result == nil { + result = err + } + case <-ctx.Done(): + if result == nil { + result = errors.New("viewer shutdown deadline exceeded") + } + } + }) + return result +} +func (v *Viewer) guard(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + if !allowedHost(r.Host) { + http.Error(w, "local viewer only", http.StatusForbidden) + return + } + if origin := r.Header.Get("Origin"); origin != "" { + parsed, err := url.Parse(origin) + if err != nil || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Scheme != "http" || !strings.EqualFold(parsed.Host, r.Host) { + http.Error(w, "same-origin viewer only", http.StatusForbidden) + return + } + } + next.ServeHTTP(w, r) + }) +} +func allowedHost(hostport string) bool { + host, _, err := net.SplitHostPort(hostport) + if err != nil { + return false + } + return strings.EqualFold(host, "127.0.0.1") || strings.EqualFold(host, "localhost") +} +func (v *Viewer) index(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(indexHTML) +} + +var indexHTML = []byte(` +MQTT test viewer +

ROBOT MARKET · TEST LAB

MQTT message viewer

Live, read-only view of the Go observer event stream.

starting
+

Compatibility evidence

A message arriving does not establish the device’s MQTT version or native MQTT 5 feature support.

FeatureStatusEvidence
+

Event timeline

Incoming message previews. Pause affects the display, not MQTT processing.

connecting
+
+

`) + +func (v *Viewer) snapshot(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + eventsList, counts := v.store.Snapshot() + var gap *events.Notice + if len(eventsList) > maxReplay { + reset := eventsList[len(eventsList)-maxReplay].ID - 1 + gap = &events.Notice{Gap: uint64(len(eventsList) - maxReplay), ResetID: reset} + eventsList = eventsList[len(eventsList)-maxReplay:] + } else if len(eventsList) > 0 && eventsList[0].ID > 1 { + gap = &events.Notice{Gap: eventsList[0].ID - 1, ResetID: eventsList[0].ID - 1} + } + snap := makeSnapshot(eventsList, counts) + snap.Gap = gap + if err := json.NewEncoder(w).Encode(snap); err != nil { + return + } +} +func makeSnapshot(list []events.Event, counts map[events.Kind]uint64) Snapshot { + connection := "starting" + var last uint64 + for _, e := range list { + if e.ID > last { + last = e.ID + } + switch e.Kind { + case events.ConnectionUp: + connection = "connected" + case events.ConnectionDown: + connection = "disconnected" + } + } + compatible := []Compatibility{ + {Feature: "Go backend MQTT 5 session", Status: "not_tested", Evidence: "No connection event recorded in the retained viewer history."}, + {Feature: "Device JSON telemetry/status contract", Status: "not_tested", Evidence: "No device payload has been validated in this observer session."}, + {Feature: "ESP32 native response/correlation round trip", Status: "not_tested", Evidence: "The viewer is read-only and cannot issue a correlated command. Payload arrival alone is not proof."}, + } + for _, e := range list { + if e.Kind == "scenario_result" && e.Scenario != nil { + r := e.Scenario + compatible = append(compatible, Compatibility{Feature: r.Feature, Status: r.Status, Evidence: "Reported " + r.Environment + " · " + r.Endpoint + " · " + r.Version + " · " + r.Time.Format(time.RFC3339) + " · " + r.Observation}) + } + if e.Kind == events.ConnectionUp && strings.Contains(e.Outcome, "protocol=5") { + compatible[0].Status = "passed" + compatible[0].Evidence = "Go MQTT 5 observer connection established (client " + safeEvidence(e.ClientID) + "). This says nothing about an ESP32 session." + } + if e.Kind == events.MessageReceived && !strings.Contains(e.Outcome, "contract validation disabled") && strings.Contains(e.Topic, "/telemetry") && e.Payload.Text != "" { + compatible[1].Status = "passed" + compatible[1].Evidence = "A telemetry JSON payload matched the test contract for device " + safeEvidence(e.DeviceID) + ". This does not identify the device implementation or prove ESP32 compatibility." + } + } + return Snapshot{Events: list, Counts: counts, Connection: connection, LastEventID: last, Compatibility: compatible} +} +func safeEvidence(s string) string { + if len(s) > 64 { + return s[:64] + } + return s +} +func (v *Viewer) stream(w http.ResponseWriter, r *http.Request) { + after, err := cursor(r) + if err != nil { + http.Error(w, "invalid event cursor", http.StatusBadRequest) + return + } + select { + case v.slots <- struct{}{}: + defer func() { <-v.slots }() + case <-r.Context().Done(): + return + default: + http.Error(w, "viewer connection limit reached", http.StatusServiceUnavailable) + return + } + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "stream unsupported", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache, no-store") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + flusher.Flush() + v.active.Add(1) + defer v.active.Add(-1) + replay, gap, stream, unsubscribe := v.store.Subscribe(after, v.cfg.ViewerQueue, maxReplay) + defer unsubscribe() + if gap != nil { + if !writeSSE(w, flusher, "gap", gap.ResetID, *gap) { + return + } + } + for _, event := range replay { + if !writeSSE(w, flusher, "mqtt-event", event.ID, events.Notice{Event: &event}) { + return + } + } + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + for { + select { + case <-r.Context().Done(): + return + case notice, open := <-stream: + if !open { + return + } + if notice.Gap > 0 { + if !writeSSE(w, flusher, "gap", notice.ResetID, notice) { + return + } + } + if notice.Event != nil { + if !writeSSE(w, flusher, "mqtt-event", notice.Event.ID, notice) { + return + } + } + case <-ticker.C: + controller := http.NewResponseController(w) + _ = controller.SetWriteDeadline(time.Now().Add(3 * time.Second)) + if _, err := fmt.Fprint(w, ": keepalive\n\n"); err != nil { + return + } + flusher.Flush() + } + } +} +func cursor(r *http.Request) (uint64, error) { + value := r.Header.Get("Last-Event-ID") + if value == "" { + value = r.URL.Query().Get("after") + } + if value == "" { + return 0, nil + } + n, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return 0, errors.New("bad event cursor") + } + return n, nil +} +func writeSSE(w http.ResponseWriter, f http.Flusher, name string, id uint64, value any) bool { + data, err := json.Marshal(value) + if err != nil { + return false + } + controller := http.NewResponseController(w) + if err = controller.SetWriteDeadline(time.Now().Add(3 * time.Second)); err != nil && !errors.Is(err, http.ErrNotSupported) { + return false + } + if id > 0 { + if _, err = fmt.Fprintf(w, "id: %d\n", id); err != nil { + return false + } + } + if _, err = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", name, data); err != nil { + return false + } + f.Flush() + return true +} +func (v *Viewer) ActiveViewers() int { return int(v.active.Load()) } diff --git a/internal/web/server_test.go b/internal/web/server_test.go new file mode 100644 index 0000000..397dd3c --- /dev/null +++ b/internal/web/server_test.go @@ -0,0 +1,164 @@ +package web + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http/httptest" + "strings" + "testing" + "time" + + "robot-market/test-mqtt-5/internal/config" + "robot-market/test-mqtt-5/internal/events" +) + +func testViewer() *Viewer { + c := config.Defaults() + return New(c.Web, events.NewStore(8, slog.New(slog.NewTextHandler(io.Discard, nil))), time.Second) +} + +func TestHostOriginAndSecurityHeaders(t *testing.T) { + v := testViewer() + cases := []struct { + name, host, origin string + want int + }{ + {"local", "127.0.0.1:8080", "", 200}, + {"same origin", "127.0.0.1:8080", "http://127.0.0.1:8080", 200}, + {"foreign host", "evil.example", "", 403}, + {"foreign origin", "127.0.0.1:8080", "http://evil.example", 403}, + {"null origin", "127.0.0.1:8080", "null", 403}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest("GET", "http://127.0.0.1/", nil) + r.Host = tc.host + if tc.origin != "" { + r.Header.Set("Origin", tc.origin) + } + w := httptest.NewRecorder() + v.Handler().ServeHTTP(w, r) + if w.Code != tc.want { + t.Fatalf("got %d, want %d", w.Code, tc.want) + } + if w.Code == 200 { + for key, want := range map[string]string{"Content-Security-Policy": "default-src 'none'", "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY"} { + if !strings.Contains(w.Header().Get(key), want) { + t.Fatalf("missing %s", key) + } + } + } + }) + } +} + +func TestAssetsUseTextOnlyRendering(t *testing.T) { + script, err := assets.ReadFile("assets/app.js") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(script), ".textContent") || strings.Contains(string(script), ".innerHTML") || strings.Contains(string(script), "insertAdjacentHTML") { + t.Fatal("viewer must render event data with text-only DOM sinks") + } +} + +func TestSnapshotEscapesHostileValuesAndSeparatesCompatibility(t *testing.T) { + v := testViewer() + hostile := "" + v.store.Add(events.Event{Kind: events.MessageReceived, Direction: events.In, Outcome: hostile, DeviceID: "sim-001", Topic: "robot-market/test/v1/sim-001/status"}) + v.store.Add(events.Event{Kind: events.ConnectionUp, Direction: events.Internal, Outcome: "session_present=false; protocol=5", ClientID: "observer"}) + r := httptest.NewRequest("GET", "http://127.0.0.1:8080/api/snapshot", nil) + r.Host = "127.0.0.1:8080" + w := httptest.NewRecorder() + v.Handler().ServeHTTP(w, r) + if w.Code != 200 || strings.Contains(w.Body.String(), hostile) || !strings.Contains(w.Body.String(), `\u003cscript\u003e`) { + t.Fatalf("hostile content not JSON-escaped: %s", w.Body.String()) + } + var data Snapshot + if err := json.Unmarshal(w.Body.Bytes(), &data); err != nil { + t.Fatal(err) + } + if data.Connection != "connected" || data.Compatibility[0].Status != "passed" || data.Compatibility[1].Status != "not_tested" || data.Compatibility[2].Status != "not_tested" { + t.Fatal("connection/compatibility evidence conflated") + } +} + +func TestSSEReplaysGapAndRejectsBadCursor(t *testing.T) { + v := testViewer() + for i := 0; i < 10; i++ { + v.store.Add(events.Event{Kind: events.MessageReceived}) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + r := httptest.NewRequest("GET", "http://127.0.0.1:8080/api/events?after=1", nil).WithContext(ctx) + r.Host = "127.0.0.1:8080" + w := httptest.NewRecorder() + done := make(chan struct{}) + go func() { v.Handler().ServeHTTP(w, r); close(done) }() + time.Sleep(10 * time.Millisecond) + cancel() + <-done + body := w.Body.String() + if w.Code != 200 || !strings.Contains(body, "event: gap") || !strings.Contains(body, `"reset_id":2`) || !strings.Contains(body, "id: 3") { + t.Fatalf("replay gap missing: %s", body) + } + bad := httptest.NewRequest("GET", "http://127.0.0.1:8080/api/events?after=bad", nil) + bad.Host = "127.0.0.1:8080" + out := httptest.NewRecorder() + v.Handler().ServeHTTP(out, bad) + if out.Code != 400 { + t.Fatal("bad cursor accepted") + } +} + +func TestSSEViewerLimitAndDisconnectCleanup(t *testing.T) { + c := config.Defaults() + c.Web.MaxViewers = 1 + v := New(c.Web, events.NewStore(8, slog.New(slog.NewTextHandler(io.Discard, nil))), time.Second) + request, stop := context.WithCancel(context.Background()) + defer stop() + req := httptest.NewRequest("GET", "http://127.0.0.1:8080/api/events", nil).WithContext(request) + req.Host = "127.0.0.1:8080" + response := httptest.NewRecorder() + done := make(chan struct{}) + go func() { v.Handler().ServeHTTP(response, req); close(done) }() + deadline := time.Now().Add(time.Second) + for v.ActiveViewers() != 1 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + second := httptest.NewRecorder() + secondRequest := httptest.NewRequest("GET", "http://127.0.0.1:8080/api/events", nil) + secondRequest.Host = "127.0.0.1:8080" + v.Handler().ServeHTTP(second, secondRequest) + if second.Code != 503 { + t.Fatal("viewer limit not enforced") + } + stop() + <-done + deadline = time.Now().Add(time.Second) + for v.ActiveViewers() != 0 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if v.ActiveViewers() != 0 { + t.Fatal("viewer disconnect was not cleaned up") + } +} + +func TestReportedScenariosDoNotClaimHardwareCompatibility(t *testing.T) { + r := &events.ScenarioResult{Feature: "session expiry", Environment: "Go clients", Endpoint: "tls://test:8884", Version: "MQTT 5", Status: "passed", Observation: "session absent after expiry", Time: time.Now().UTC()} + v := testViewer() + v.store.Add(events.Event{Kind: "scenario_result", Scenario: r}) + r.Feature = "mutated" + list, counts := v.store.Snapshot() + snap := makeSnapshot(list, counts) + if len(snap.Compatibility) != 4 || snap.Compatibility[3].Feature != "session expiry" || snap.Compatibility[2].Status != "not_tested" || !strings.Contains(snap.Compatibility[3].Evidence, "Reported Go clients") { + t.Fatal("reported evidence or hardware distinction lost") + } + list[0].Scenario.Feature = "mutated snapshot" + again, _ := v.store.Snapshot() + if again[0].Scenario.Feature != "session expiry" { + t.Fatal("scenario aliases retained history") + } +} diff --git a/postman/MQTT Test Viewer.postman_collection.json b/postman/MQTT Test Viewer.postman_collection.json new file mode 100644 index 0000000..d8151db --- /dev/null +++ b/postman/MQTT Test Viewer.postman_collection.json @@ -0,0 +1,66 @@ +{ + "info": { + "_postman_id": "9c586a0e-2db4-4c70-963c-12b41d983b52", + "name": "MQTT Test Viewer (HTTP checks)", + "description": "Read-only checks for the local MQTT test viewer. The collection checks the viewer page and its current snapshot. To publish a test MQTT message, use the manual recipe in postman/README.md; Postman HTTP and MQTT requests must be saved in separate collections.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { + "key": "viewerUrl", + "value": "http://127.0.0.1:8080", + "type": "string" + } + ], + "item": [ + { + "name": "1. Viewer page is available", + "request": { + "method": "GET", + "header": [], + "url": "{{viewerUrl}}/", + "description": "Confirms the browser viewer is serving its page." + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('viewer returns HTTP 200', function () { pm.response.to.have.status(200); });", + "pm.test('viewer page is HTML', function () { pm.expect(pm.response.headers.get('Content-Type')).to.include('text/html'); });", + "pm.test('page identifies the MQTT message viewer', function () { pm.expect(pm.response.text()).to.include('MQTT message viewer'); });" + ] + } + } + ] + }, + { + "name": "2. Snapshot shows broker and events", + "request": { + "method": "GET", + "header": [], + "url": "{{viewerUrl}}/api/snapshot", + "description": "Returns the current observer connection state, event counts, compatibility evidence, and bounded event history. Run this after publishing MQTT telemetry to check whether the observer received it." + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('snapshot returns HTTP 200 JSON', function () { pm.response.to.have.status(200); pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'); });", + "const snapshot = pm.response.json();", + "pm.test('observer is connected to the broker', function () { pm.expect(snapshot.connection).to.eql('connected'); });", + "pm.test('snapshot contains event history and counters', function () { pm.expect(snapshot.events).to.be.an('array'); pm.expect(snapshot.counts).to.be.an('object'); pm.expect(snapshot.last_event_id).to.be.a('number'); });", + "const received = (snapshot.counts && snapshot.counts.message_received) || 0;", + "pm.test('observer has received at least one MQTT message', function () { pm.expect(received, 'Send telemetry using the MQTT recipe, then run this request again').to.be.above(0); });", + "const latest = snapshot.events && snapshot.events.length ? snapshot.events[snapshot.events.length - 1] : null;", + "if (latest) console.log('Latest viewer event:', latest.kind, latest.topic || '', latest.payload && latest.payload.text || '');" + ] + } + } + ] + } + ] +} diff --git a/postman/README.md b/postman/README.md new file mode 100644 index 0000000..b689223 --- /dev/null +++ b/postman/README.md @@ -0,0 +1,21 @@ +# Manually test the broker and browser viewer with Postman + +In Postman v12, open the **project root** `/home/ryan/Media/Code/Code/robot-market/test-mqtt-5` using **Files → Open folder**, then select **Local View**. This MQTT collection uses Postman's v3 multi-file format; it is **not a standalone file to choose from Import**. Do not select the `.request.yaml` file or the nested collection directory in the Import dialog. Postman discovers the collection at `postman/collections/MQTT Broker UI Test`. Keep the observer container running and open in your browser. + +If you still get “We don't recognize this file” after opening the project root via **Files → Open folder**, bypass the saved collection and create the request inside Postman: click **+ → MQTT**, enter `mqtt://109.122.252.129:1884`, set MQTT version to **5.0**, enter the `sim-001` test username/password under the request's connection/auth settings, and click **Connect**. Then follow “Publish a test telemetry message” below. The message's topic and payload are the actual test inputs; the MQTT collection file only pre-fills the broker URL and provides notes. + +The older `MQTT Test Viewer.postman_collection.json` file is a Postman v2.1 HTTP collection with two read-only checks for the viewer page and `/api/snapshot`. The MQTT v3 collection below is for broker traffic. To subscribe to telemetry in Postman, use the **test-backend** credentials; device credentials can only subscribe to their commands topic. To publish as ESP32, use the **esp32-lab-001** credentials and its matching topic path. Current Postman supports MQTT requests in its v3 multi-protocol collection format. + +## Quick functional test + +1. Connect to `mqtt://109.122.252.129:1884` with any existing test account (including `esp32-lab-001`) and its current password. Select MQTT **5.0** and use a unique client ID such as `postman-functional-test-01`. +2. Publish `hello from postman` to `robot-market/test/v1/sim-001/telemetry`, QoS **1**, retain **off**. +3. Open http://127.0.0.1:8080/. It should appear as `message_received` with its raw contents. + +The test broker no longer restricts topics by username. Any test account may also subscribe to that topic in Postman to receive its own publication. The browser watches all topics under `robot-market/test/v1/#` and applies no JSON, device-ID or timestamp validation. Your previous JSON payload, including a literal `{{$isoTimestamp}}`, is displayed as supplied. + +TLS is also available at `mqtts://109.122.252.129:8884` with the supplied private CA. If a Postman send does not appear, inspect the connection state and publish acknowledgement. The Go simulator is not needed. + +## Receive the backend's application ACK + +In Topics, subscribe to `robot-market/test/v1/sim-001/acks` **before** publishing to `robot-market/test/v1/sim-001/telemetry`. Every live test payload gets a JSON `receipt_ack` reply with `ok=true`, source topic, observed time, byte count and SHA-256. Optional message_id/request_id are echoed; they are not required. For ESP32's own topic path, subscribe to `robot-market/test/v1/esp32-lab-001/acks`. The UI shows `receipt_ack_sent` and outbound publication events. This is separate from MQTT PUBACK. diff --git a/postman/collections/MQTT Broker UI Test/.resources/definition.yaml b/postman/collections/MQTT Broker UI Test/.resources/definition.yaml new file mode 100644 index 0000000..356b698 --- /dev/null +++ b/postman/collections/MQTT Broker UI Test/.resources/definition.yaml @@ -0,0 +1,3 @@ +$kind: collection +name: MQTT Broker UI Test +description: Connect Postman to the test MQTT broker and publish test-device telemetry for the local browser viewer. diff --git a/postman/collections/MQTT Broker UI Test/Connect to Test Broker.request.yaml b/postman/collections/MQTT Broker UI Test/Connect to Test Broker.request.yaml new file mode 100644 index 0000000..2351135 --- /dev/null +++ b/postman/collections/MQTT Broker UI Test/Connect to Test Broker.request.yaml @@ -0,0 +1,24 @@ +$kind: mqtt-request +name: Connect to Test Broker +url: mqtt://109.122.252.129:1884 +description: |- + Connect with MQTT 5.0 using the sim-001 test account. Keep the local observer + running at http://127.0.0.1:8080. + + Publish to robot-market/test/v1/sim-001/telemetry using QoS 1 and Retain off. + Use a new message_id each time and replace the timestamp with the current UTC + time (RFC3339, ending in Z). + + { + "schema_version": 1, + "device_id": "sim-001", + "message_id": "postman-ui-test-001", + "boot_id": "postman-ui-session-001", + "sequence": 1, + "timestamp": "2026-10-06T12:00:00Z", + "readings": { + "temperature_c": 24.75, + "test_value": 123 + } + } +order: 1000 diff --git a/scripts/check_receipts.py b/scripts/check_receipts.py new file mode 100644 index 0000000..5e7623b --- /dev/null +++ b/scripts/check_receipts.py @@ -0,0 +1,240 @@ +import json +import socket +import ssl +import struct +import time +import urllib.request +import uuid +from pathlib import Path + +import argparse +parser = argparse.ArgumentParser(description='Independent test-device/backend receipt ACK checks; does not run the Go simulator.') +parser.add_argument('--credentials', required=True, help='Private JSON handoff or ESP32 connection.txt file') +parser.add_argument('--ca', required=True, help='Test broker public CA file') +parser.add_argument('--host', default='109.122.252.129') +parser.add_argument('--plaintext-port', type=int, default=1884) +parser.add_argument('--tls-port', type=int, default=8884) +args = parser.parse_args() +HOST = args.host +private = Path(args.credentials).read_text() +if args.credentials.endswith('.json'): + connection = json.loads(private) + device_user, device_password = connection['username'], connection['password'] +else: + connection = dict(line.split('=', 1) for line in private.splitlines() if '=' in line) + device_user, device_password = connection['mqtt_user'].strip(), connection['mqtt_pass'].strip() + +def varint(n): + out = bytearray() + while True: + byte = n % 128 + n //= 128 + out.append(byte | (128 if n else 0)) + if not n: + return bytes(out) + +def string(s): + raw = s.encode() + return struct.pack('!H', len(raw)) + raw + +def exact(sock, n): + result = b'' + while len(result) < n: + chunk = sock.recv(n - len(result)) + if not chunk: + raise RuntimeError('broker closed connection') + result += chunk + return result + +def read(sock): + header = exact(sock, 1)[0] + length = 0 + factor = 1 + while True: + digit = exact(sock, 1)[0] + length += (digit & 127) * factor + if not digit & 128: + break + factor *= 128 + return header, exact(sock, length) + +def send(sock, header, body): + sock.sendall(bytes([header]) + varint(len(body)) + body) + +def connect(version, tls, username, password, client_id=None, persistent=False, expect_session=False, anonymous=False): + sock = socket.create_connection((HOST, args.tls_port if tls else args.plaintext_port), timeout=8) + if tls: + context = ssl.create_default_context(cafile=args.ca) + sock = context.wrap_socket(sock, server_hostname=HOST) + body = string('MQTT') + bytes([version, 2 if anonymous else (0xc0 if persistent else 0xc2)]) + struct.pack('!H', 30) + if version == 5: + body += b'\x05\x11' + struct.pack('!I', 30) if persistent else b'\x00' + body += string(client_id or 'rm5-independent-' + uuid.uuid4().hex[:12]) + (b'' if anonymous else string(username) + string(password)) + send(sock, 0x10, body) + header, body = read(sock) + if header >> 4 != 2 or body[1] != 0: + raise RuntimeError('authentication/CONNECT rejected: code ' + str(body[1])) + if expect_session and not body[0] & 1: + raise RuntimeError('persistent session absent; retransmission test inconclusive') + return sock + +def subscribe(sock, version, topic): + body = b'\x00\x01' + (b'\x00' if version == 5 else b'') + string(topic) + b'\x01' + send(sock, 0x82, body) + header, body = read(sock) + if header >> 4 != 9 or body[-1] >= 128: + raise RuntimeError('backend subscription rejected') + +def publish(sock, version, topic, payload): + body = string(topic) + b'\x00\x02' + (b'\x00' if version == 5 else b'') + payload.encode() + send(sock, 0x32, body) + header, body = read(sock) + if header >> 4 != 4 or (version == 5 and len(body) > 2 and body[2] >= 128): + raise RuntimeError('publication denied by broker') + +def received(sock, version): + header, body = read(sock) + if header >> 4 != 3: + raise RuntimeError('expected subscribed publication') + topic_len = int.from_bytes(body[:2], 'big') + position = 2 + topic_len + if (header >> 1) & 3: + packet_id = body[position:position+2] + position += 2 + send(sock, 0x40, packet_id) + if version == 5: + size = 0 + factor = 1 + while True: + digit = body[position] + position += 1 + size += (digit & 127) * factor + if not digit & 128: + break + factor *= 128 + position += size + return body[position:].decode() + +def viewer_has(topic, payload, kind): + until = time.monotonic() + 5 + while time.monotonic() < until: + with urllib.request.urlopen('http://127.0.0.1:8080/api/snapshot', timeout=3) as response: + snapshot = json.load(response) + if any(event.get('topic') == topic and event.get('kind') == kind and event.get('payload', {}).get('text', '') == payload for event in snapshot['events']): + return + time.sleep(0.1) + raise RuntimeError('broker delivered but viewer did not show ' + kind) + + +import hashlib +for tls in (False, True): + for version in (4,5): + device='ack-test-'+uuid.uuid4().hex[:8] + source='robot-market/test/v1/'+device+'/telemetry' + acktopic='robot-market/test/v1/'+device+'/acks' + reader=connect(version,tls,device_user,device_password) + writer=connect(version,tls,device_user,device_password) + try: + subscribe(reader,version,acktopic) + for payload in ['hello from device', json.dumps({'message_id':'receipt-001','device_id':'anything','timestamp':'{{$isoTimestamp}}'}), '']: + publish(writer,version,source,payload) + ack_payload=received(reader,version) + ack=json.loads(ack_payload) + viewer_has(source,payload,'message_received') + viewer_has(acktopic,ack_payload,'receipt_ack_sent') + assert ack['type']=='receipt_ack' and ack['ok'] is True + assert ack['source_topic']==source and ack['payload_bytes']==len(payload.encode()) + assert ack['payload_sha256']==hashlib.sha256(payload.encode()).hexdigest() + if 'receipt-001' in payload: assert ack['message_id']=='receipt-001' + reader.settimeout(.3) + try: + extra=read(reader) + except socket.timeout: + pass + else: + raise RuntimeError('Unexpected reply loop or extra ACK: '+str(extra[0])) + print(('TLS' if tls else 'plaintext')+' MQTT '+('5' if version==5 else '3.1.1')+': device receives backend ACK for text, arbitrary JSON, empty payload; no loop PASS') + finally: + for sock in (writer,reader): + send(sock,0xe0,b'') + sock.close() +# Native MQTT 5 custom response topic and binary correlation echo. +source='robot-market/test/v1/native-ack-test/telemetry' +response='robot-market/test/v1/native-ack-test/custom-reply' +corr=bytes([0,255,1,2]) +reader=connect(5,False,device_user,device_password) +writer=connect(5,False,device_user,device_password) +try: + subscribe(reader,5,response) + props=b'\x08'+string(response)+b'\x09'+struct.pack('!H',len(corr))+corr + body=string(source)+b'\x00\x02'+varint(len(props))+props+b'native properties test' + send(writer,0x32,body) + header,body=read(writer) + assert header>>4==4 and (len(body)==2 or body[2]<128) + header,body=read(reader) + assert header>>4==3 + pos=2+int.from_bytes(body[:2],'big') + packetid=body[pos:pos+2];pos+=2 + send(reader,0x40,packetid) + size=0;factor=1 + while True: + digit=body[pos];pos+=1;size+=(digit&127)*factor + if not digit&128:break + factor*=128 + receivedprops=body[pos:pos+size] + assert b'\x09'+struct.pack('!H',len(corr))+corr in receivedprops + ack=json.loads(body[pos+size:]) + assert ack['source_topic']==source and ack['ok'] is True + print('MQTT 5 Response Topic and exact binary Correlation Data echo: PASS') +finally: + for sock in (writer,reader): + send(sock,0xe0,b'') + sock.close() + +# Controlled wire-level retransmission: same persistent client/session and packet ID, +# original PUBACK deliberately unread, then DUP=1 after a dropped socket. +source = 'robot-market/test/v1/wire-dup-' + uuid.uuid4().hex[:8] + '/telemetry' +acktopic = source.rsplit('/', 1)[0] + '/acks' +reader = connect(5, False, device_user, device_password) +client_id = 'rm5-dup-' + uuid.uuid4().hex[:12] +writer = connect(5, False, device_user, device_password, client_id, True) +try: + subscribe(reader, 5, acktopic) + body = string(source) + b'\x00\x07\x00' + b'controlled wire retransmission' + send(writer, 0x32, body) + first = json.loads(received(reader, 5)) + writer.close() # no PUBACK read, no DISCONNECT + time.sleep(.2) + writer = connect(5, False, device_user, device_password, client_id, True, True) + send(writer, 0x3a, body) # QoS 1, DUP set, identical packet identifier and payload + header, response = read(writer) + assert header >> 4 == 4 and response[:2] == b'\x00\x07' + assert len(response) == 2 or response[2] < 128 + second = json.loads(received(reader, 5)) + assert first['payload_sha256'] == second['payload_sha256'] + print('Controlled MQTT 5 wire QoS 1 DUP retransmission after unread PUBACK: PASS; two receipt ACKs, no device action claimed') +finally: + for sock in (writer, reader): + send(sock, 0xe0, b'') + sock.close() + +for label, username, password, anonymous in [('Anonymous', '', '', True), ('Invalid password', device_user, uuid.uuid4().hex, False)]: + try: + sock = connect(5, True, username, password, anonymous=anonymous) + except RuntimeError as error: + assert 'CONNECT rejected' in str(error), 'failure was not broker authentication rejection' + print(label + ' authentication rejection: PASS') + else: + sock.close() + raise RuntimeError(label + ' authentication unexpectedly accepted') +for label, context, hostname in [('Untrusted CA', ssl.create_default_context(), HOST), ('Wrong hostname', ssl.create_default_context(cafile=args.ca), 'wrong.invalid')]: + raw = socket.create_connection((HOST, args.tls_port), timeout=8) + try: + secured = context.wrap_socket(raw, server_hostname=hostname) + except ssl.SSLCertVerificationError: + print(label + ' TLS rejection: PASS') + else: + secured.close() + raise RuntimeError(label + ' TLS unexpectedly accepted') + finally: + raw.close() diff --git a/server/compose.yaml b/server/compose.yaml new file mode 100644 index 0000000..919e224 --- /dev/null +++ b/server/compose.yaml @@ -0,0 +1,57 @@ +name: mqtt5-test-backend +services: + backend: + image: mqtt5-test-backend:${IMAGE_TAG:?Set IMAGE_TAG} + network_mode: host + user: "10001:10001" + init: true + read_only: true + tmpfs: ["/tmp:rw,noexec,nosuid,size=16m"] + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] + restart: unless-stopped + stop_grace_period: 10s + environment: + TEST_MQTT5_WEB__ENABLED: "true" + TEST_MQTT5_WEB__ADDRESS: "127.0.0.1:18080" + TEST_MQTT5_TOPICS__OBSERVE_ALL: "true" + TEST_MQTT5_LOGGING__RAW_PAYLOADS: "true" + TEST_MQTT5_LOGGING__FORMAT: "json" + TEST_MQTT5_RECEIPTS__ENABLED: "true" + command: + - --broker + - tls://109.122.252.129:8884 + - --ca + - /run/test-mqtt/ca.crt + - --credentials + - /run/test-mqtt/backend.json + - --client-id + - rm5-server-observer + - observe + volumes: + - type: bind + source: /opt/test-mqtt-5/backend/secrets/backend.json + target: /run/test-mqtt/backend.json + read_only: true + bind: + create_host_path: false + - type: bind + source: /opt/test-mqtt-5/backend/ca.crt + target: /run/test-mqtt/ca.crt + read_only: true + bind: + create_host_path: false + healthcheck: + test: + - CMD-SHELL + - >- + wget -qO- http://127.0.0.1:18080/api/snapshot | grep -q '"connection":"connected"' + interval: 10s + timeout: 4s + retries: 6 + start_period: 15s + logging: + driver: local + options: + max-size: 10m + max-file: "3" diff --git a/server/deploy.sh b/server/deploy.sh new file mode 100644 index 0000000..6f7a24e --- /dev/null +++ b/server/deploy.sh @@ -0,0 +1,47 @@ +#!/bin/sh +# Run on the deployment host, or in the runner with /runtime bind-mounted. +set -eu +TAG=${1:?Usage: deploy.sh IMAGE_TAG} +case "$TAG" in *[!A-Za-z0-9_.-]*|'') echo "Invalid image tag" >&2; exit 2;; esac +RUNTIME=${MQTT5_RUNTIME:-/opt/test-mqtt-5/backend} +mkdir -p "$RUNTIME" +# Serialize manual and CI deployments on the shared mounted directory. +exec 9>"$RUNTIME/deploy.lock" +flock -w 120 9 +[ -s "$RUNTIME/secrets/backend.json" ] && [ -s "$RUNTIME/ca.crt" ] || { echo "Server credentials/CA missing" >&2; exit 1; } +docker image inspect "mqtt5-test-backend:$TAG" >/dev/null +SOURCE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +HAD_PREVIOUS=false +if [ -s "$RUNTIME/.env" ] && [ -s "$RUNTIME/compose.yaml" ]; then + cp "$RUNTIME/.env" "$RUNTIME/.env.previous" + cp "$RUNTIME/compose.yaml" "$RUNTIME/compose.previous.yaml" + HAD_PREVIOUS=true +fi +rollback() { + if [ "$HAD_PREVIOUS" = true ]; then + cp "$RUNTIME/.env.previous" "$RUNTIME/.env" + cp "$RUNTIME/compose.previous.yaml" "$RUNTIME/compose.yaml" + docker compose --project-directory "$RUNTIME" --env-file "$RUNTIME/.env" -f "$RUNTIME/compose.yaml" up -d --force-recreate backend + echo "Previous backend release restored; inspect its health." + else + docker compose --project-directory "$RUNTIME" --env-file "$RUNTIME/.env" -f "$RUNTIME/compose.yaml" stop backend || true + echo "First deployment failed; backend stopped." + fi +} +cp "$SOURCE/compose.yaml" "$RUNTIME/compose.next.yaml" +printf 'IMAGE_TAG=%s\n' "$TAG" > "$RUNTIME/.env.next" +if ! docker compose --project-directory "$RUNTIME" --env-file "$RUNTIME/.env.next" -f "$RUNTIME/compose.next.yaml" config --quiet; then + echo "Compose validation failed; running backend untouched" >&2; exit 1 +fi +mv "$RUNTIME/compose.next.yaml" "$RUNTIME/compose.yaml" +mv "$RUNTIME/.env.next" "$RUNTIME/.env" +if ! docker compose --project-directory "$RUNTIME" --env-file "$RUNTIME/.env" -f "$RUNTIME/compose.yaml" up -d --force-recreate backend; then rollback; exit 1; fi +CID=$(docker compose --project-directory "$RUNTIME" --env-file "$RUNTIME/.env" -f "$RUNTIME/compose.yaml" ps -q backend) +for attempt in $(seq 1 30); do + HEALTH=$(docker inspect --format '{{.State.Health.Status}}' "$CID" 2>/dev/null || true) + if [ "$HEALTH" = healthy ]; then echo "Backend healthy: $TAG"; exit 0; fi + sleep 2 +done +echo "Backend health failed; rolling back" >&2 +rollback +exit 1