Add MQTT enrollment demo with Gitea deploy pipeline
Test and deploy enrollment demo / test (push) Successful in 7s
Test and deploy enrollment demo / deploy (push) Successful in 1m12s

Go backend plus C (libsodium) and C++ (OpenSSL) simulated boards enroll
through a Mosquitto 2.1 broker with Dynamic Security using X25519/HKDF/
Ed25519; a web view shows the run live. The Gitea workflow validates and
builds the images, redeploys the stack on the server with the viewer on
port 8099 and fails unless both boards pass all checks.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 21:25:14 +03:30
co-authored by Claude Sonnet 5.5
commit bf40b92c52
109 changed files with 15357 additions and 0 deletions
+366
View File
@@ -0,0 +1,366 @@
// Live log viewer for the enrollment demo. Tails /logs/*.log (written by the backend and the two
// boards) and streams the parsed lines to the web page over Server-Sent Events. It can also restart
// the whole demo (the "Run again" button) by talking to the Docker Engine API. Stdlib only.
package main
import (
"context"
"embed"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
//go:embed index.html
var assets embed.FS
// line is one parsed log line: "HH:MM:SS.mmm [actor] message". Seq is its arrival order.
type line struct {
Seq int `json:"seq"`
Time string `json:"time"`
Tag string `json:"tag"`
Msg string `json:"msg"`
}
// status is what the page shows next to the "Run again" button.
type status struct {
Text string `json:"text"`
Busy bool `json:"busy"`
}
var (
mu sync.Mutex
lines []line
gen int // incremented whenever the history is cleared (a new run), so clients can reset too
state = status{Text: "ready"}
logDir = "/logs"
// The restart button needs the Docker socket; it is only enabled when the deployment says so.
restartEnabled = os.Getenv("RESTART_ENABLED") == "1"
re = regexp.MustCompile(`^(\d\d:\d\d:\d\d\.\d{3}) \[([a-z-]+)\] (.*)$`)
)
// tail remembers how far a file has been read and any incomplete last line.
type tail struct {
offset int64
partial string
}
// tailLoop polls every *.log file in dir every 250 ms, parses the new complete lines and appends
// them to the shared slice. If a file shrinks (a new run truncated it) it is read from the start,
// and after a reset all read positions are forgotten.
func tailLoop(dir string) {
files := map[string]*tail{}
seenGen := 0
for {
mu.Lock()
if gen != seenGen {
files, seenGen = map[string]*tail{}, gen
}
mu.Unlock()
paths, _ := filepath.Glob(filepath.Join(dir, "*.log"))
for _, p := range paths {
t := files[p]
if t == nil {
t = &tail{}
files[p] = t
}
st, err := os.Stat(p)
if err != nil {
continue
}
if st.Size() < t.offset { // file was truncated by a new run
t.offset, t.partial = 0, ""
}
if st.Size() == t.offset {
continue
}
f, err := os.Open(p)
if err != nil {
continue
}
buf := make([]byte, st.Size()-t.offset)
n, _ := f.ReadAt(buf, t.offset)
f.Close()
t.offset += int64(n)
data := t.partial + string(buf[:n])
parts := strings.Split(data, "\n")
t.partial = parts[len(parts)-1]
for _, l := range parts[:len(parts)-1] {
if m := re.FindStringSubmatch(l); m != nil {
mu.Lock()
lines = append(lines, line{Seq: len(lines), Time: m[1], Tag: m[2], Msg: m[3]})
mu.Unlock()
}
}
}
time.Sleep(250 * time.Millisecond)
}
}
func setStatus(text string, busy bool) {
mu.Lock()
state = status{Text: text, Busy: busy}
mu.Unlock()
}
// clearHistory forgets every line and deletes the log files, so the next run starts clean.
func clearHistory() {
mu.Lock()
lines = nil
gen++
mu.Unlock()
old, _ := filepath.Glob(filepath.Join(logDir, "*.log"))
for _, p := range old {
os.Remove(p)
}
}
// ---- Docker Engine API (over the mounted unix socket) ----
type docker struct{ c *http.Client }
func newDocker() docker {
return docker{c: &http.Client{
Timeout: 60 * time.Second,
Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", "/var/run/docker.sock")
}},
}}
}
func (d docker) call(method, path string) ([]byte, int, error) {
req, err := http.NewRequest(method, "http://docker"+path, nil)
if err != nil {
return nil, 0, err
}
resp, err := d.c.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return b, resp.StatusCode, nil
}
// containers returns compose service name -> container id for this demo's project.
func (d docker) containers(project string) (map[string]string, error) {
filter := url.QueryEscape(fmt.Sprintf(`{"label":["com.docker.compose.project=%s"]}`, project))
b, code, err := d.call("GET", "/containers/json?all=1&filters="+filter)
if err != nil {
return nil, err
}
if code != 200 {
return nil, fmt.Errorf("docker returned %d: %s", code, strings.TrimSpace(string(b)))
}
var list []struct {
ID string `json:"Id"`
Labels map[string]string `json:"Labels"`
}
if err := json.Unmarshal(b, &list); err != nil {
return nil, err
}
out := map[string]string{}
for _, c := range list {
out[c.Labels["com.docker.compose.service"]] = c.ID
}
return out, nil
}
func (d docker) action(id, verb string) error {
_, code, err := d.call("POST", "/containers/"+id+"/"+verb+"?t=2")
if err != nil {
return err
}
if code >= 300 && code != 304 { // 304 = already in that state
return fmt.Errorf("docker %s returned %d", verb, code)
}
return nil
}
func (d docker) healthy(id string) bool {
b, code, err := d.call("GET", "/containers/"+id+"/json")
if err != nil || code != 200 {
return false
}
var info struct {
State struct {
Running bool
Health *struct{ Status string }
}
}
if json.Unmarshal(b, &info) != nil {
return false
}
if info.State.Health != nil {
return info.State.Health.Status == "healthy"
}
return info.State.Running
}
// restartDemo performs a clean re-run: stop the backend and boards, restart the broker (which
// recreates its Dynamic Security state), clear the old logs, then start the backend and boards
// again. Their own start delays then play out exactly like the first run.
func restartDemo() {
project := os.Getenv("COMPOSE_PROJECT")
if project == "" {
project = "crypto-interop-spike"
}
d := newDocker()
ids, err := d.containers(project)
if err != nil {
setStatus("cannot reach Docker: "+err.Error(), false)
return
}
for _, s := range []string{"backend", "board-c", "board-cpp", "broker"} {
if ids[s] == "" {
setStatus("container for service "+s+" not found", false)
return
}
}
setStatus("stopping the backend and boards...", true)
for _, s := range []string{"board-c", "board-cpp", "backend"} {
if err := d.action(ids[s], "stop"); err != nil {
setStatus("stop "+s+": "+err.Error(), false)
return
}
}
setStatus("restarting the broker (fresh Dynamic Security state)...", true)
if err := d.action(ids["broker"], "restart"); err != nil {
setStatus("restart broker: "+err.Error(), false)
return
}
ok := false
for i := 0; i < 60 && !ok; i++ {
time.Sleep(500 * time.Millisecond)
ok = d.healthy(ids["broker"])
}
if !ok {
setStatus("the broker did not become healthy", false)
return
}
setStatus("clearing the old logs...", true)
clearHistory()
setStatus("starting the backend and boards...", true)
for _, s := range []string{"backend", "board-c", "board-cpp"} {
if err := d.action(ids[s], "start"); err != nil {
setStatus("start "+s+": "+err.Error(), false)
return
}
}
setStatus("new run started", false)
}
// main: clear stale logs from the previous run, start the tailer, then serve:
//
// / the web page (embedded index.html)
// /api/lines all lines so far as JSON (handy for debugging)
// /api/restart POST: restart the whole demo (needs the X-Demo-Action header, see below)
// /events Server-Sent Events: replays every line, then pushes new ones live, plus
// "reset" (history cleared) and "status" (restart progress) events
func main() {
if len(os.Args) > 1 {
logDir = os.Args[1]
}
os.MkdirAll(logDir, 0o755)
clearHistory() // stale logs from a previous run
go tailLoop(logDir)
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
b, _ := assets.ReadFile("index.html")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(b)
})
http.HandleFunc("/api/lines", func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
json.NewEncoder(w).Encode(lines)
})
http.HandleFunc("/api/config", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]bool{"restart": restartEnabled})
})
http.HandleFunc("/api/restart", func(w http.ResponseWriter, r *http.Request) {
if !restartEnabled {
http.NotFound(w, r)
return
}
// The custom header cannot be sent by a cross-site form or fetch without a CORS preflight
// (which this server never grants), so other web pages cannot trigger a restart.
if r.Method != http.MethodPost || r.Header.Get("X-Demo-Action") != "restart" {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
mu.Lock()
if state.Busy {
mu.Unlock()
http.Error(w, "a restart is already running", http.StatusConflict)
return
}
state = status{Text: "starting restart...", Busy: true}
mu.Unlock()
go restartDemo()
w.WriteHeader(http.StatusAccepted)
})
http.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
fl, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
idx, myGen, sent := 0, -1, status{Text: "\x00"}
for {
mu.Lock()
if myGen == -1 {
myGen = gen
}
resetNow := gen != myGen
if resetNow {
myGen, idx = gen, 0
}
batch := append([]line(nil), lines[min(idx, len(lines)):]...)
cur := state
mu.Unlock()
if resetNow {
fmt.Fprint(w, "event: reset\ndata: {}\n\n")
}
for _, l := range batch {
b, _ := json.Marshal(l)
fmt.Fprintf(w, "data: %s\n\n", b)
}
idx += len(batch)
if cur != sent {
b, _ := json.Marshal(cur)
fmt.Fprintf(w, "event: status\ndata: %s\n\n", b)
sent = cur
}
fl.Flush()
select {
case <-r.Context().Done():
return
case <-time.After(250 * time.Millisecond):
}
}
})
fmt.Println("viewer listening on :8080")
http.ListenAndServe(":8080", nil)
}