Files
mqtt-security-test/docker/viewer/main.go
T
RyanandClaude Sonnet 5.5 37ca9d4da1
Test and deploy enrollment demo / test (push) Successful in 36s
Test and deploy enrollment demo / deploy (push) Successful in 1m13s
Add Gitea restart workflow and link-style "Run again" for the public view
The server deployment cannot mount the Docker socket safely, so the web
view's button now opens the Gitea page for a workflow_dispatch workflow
that recreates the stack and replays the demo. Gitea handles who may run it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 21:54:24 +03:30

370 lines
10 KiB
Go

// 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"
// Without the Docker socket the page can still offer "Run again" as a link to this URL (the
// Gitea page that runs the restart workflow); Gitea then does the authentication.
restartURL = os.Getenv("RESTART_URL")
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]any{"restart": restartEnabled, "restartUrl": restartURL})
})
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)
}