// 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
Compatibility evidence
A message arriving does not establish the device’s MQTT version or native MQTT 5 feature support.
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()) }