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
+8
View File
@@ -0,0 +1,8 @@
FROM golang:1.24-alpine AS build
WORKDIR /src
COPY go.mod main.go index.html ./
RUN CGO_ENABLED=0 go build -o /viewer .
FROM alpine:3.21
COPY --from=build /viewer /viewer
ENTRYPOINT ["/viewer"]
+3
View File
@@ -0,0 +1,3 @@
module spike-viewer
go 1.24
+279
View File
@@ -0,0 +1,279 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Enrollment Live View</title>
<style>
:root {
--bg: #f6f7f9; --panel: #ffffff; --ink: #1d232b; --muted: #66707c; --line: #e2e6ea;
--c: #2563eb; --be: #15803d; --cpp: #c2410c;
--c-bg: #eaf1ff; --be-bg: #e8f6ee; --cpp-bg: #fdeee5;
--pass: #15803d; --fail: #b91c1c; --step-bg: #f0f2f5;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #12161b; --panel: #1a2027; --ink: #e6eaee; --muted: #93a0ad; --line: #2b333c;
--c: #7aa7ff; --be: #5fd48a; --cpp: #ff9f6b;
--c-bg: #1c2740; --be-bg: #16301f; --cpp-bg: #3a2416;
--pass: #5fd48a; --fail: #ff7b7b; --step-bg: #222a33;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--ink); font: 14px/1.45 system-ui, sans-serif; }
.wrap { max-width: 1400px; margin: 0 auto; padding: 16px; }
h1 { font-size: 20px; margin: 0 0 2px; }
.sub { color: var(--muted); margin: 0 0 14px; }
.how { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 10px 14px; margin-bottom: 14px; }
.how summary { cursor: pointer; font-weight: 600; }
.how ol { margin: 8px 0 2px 18px; padding: 0; }
.cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 14px; }
.card { background: var(--panel); border: 1px solid var(--line); border-top: 4px solid var(--accent); border-radius: 10px; padding: 10px 14px; }
.card h2 { font-size: 15px; margin: 0; color: var(--accent); }
.card .meta { color: var(--muted); font-size: 12px; margin-bottom: 6px; }
.bar { height: 6px; background: var(--line); border-radius: 3px; overflow: hidden; margin: 6px 0; }
.bar > div { height: 100%; background: var(--accent); width: 0; transition: width .3s; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.chip { font-size: 12px; padding: 2px 8px; border-radius: 999px; background: var(--step-bg); }
.chip.ok { color: var(--pass); } .chip.bad { color: var(--fail); font-weight: 600; }
.now { background: var(--panel); border: 1px solid var(--line); border-left: 4px solid var(--accent, var(--muted)); border-radius: 10px; padding: 10px 14px; margin-bottom: 14px; }
.now .who { font-weight: 600; color: var(--accent, var(--ink)); }
.tools { display: flex; gap: 16px; align-items: center; margin-bottom: 8px; color: var(--muted); font-size: 13px; flex-wrap: wrap; }
.tools .status { margin-left: auto; }
#restart { font: inherit; padding: 5px 14px; border-radius: 8px; border: 1px solid var(--line); background: var(--panel); color: var(--ink); cursor: pointer; }
#restart:hover:not(:disabled) { border-color: var(--be); }
#restart:disabled { opacity: .55; cursor: wait; }
.grid { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
.row, .head { display: grid; grid-template-columns: 92px 1fr 1fr 1fr; border-bottom: 1px solid var(--line); }
.row:last-child { border-bottom: 0; }
.head { position: sticky; top: 0; background: var(--panel); font-weight: 600; z-index: 2; }
.head div { padding: 8px 10px; border-left: 1px solid var(--line); }
.head div:first-child { border-left: 0; color: var(--muted); }
.head .h-board-c { color: var(--c); } .head .h-backend { color: var(--be); } .head .h-board-cpp { color: var(--cpp); }
.t { padding: 5px 10px; color: var(--muted); font: 12px ui-monospace, monospace; }
.cell { padding: 5px 10px; border-left: 1px solid var(--line); min-width: 0; word-break: break-word; }
.cell.empty { background: transparent; }
.row[data-tag=board-c] .cell.me { background: var(--c-bg); }
.row[data-tag=backend] .cell.me { background: var(--be-bg); }
.row[data-tag=board-cpp] .cell.me { background: var(--cpp-bg); }
.row.step .cell.me { font-weight: 700; background: var(--step-bg); border-top: 1px solid var(--line); }
.row.pass .cell.me .msg::before { content: "PASS "; color: var(--pass); font-weight: 700; }
.row.fail .cell.me .msg::before { content: "FAIL "; color: var(--fail); font-weight: 700; }
.row.fail .cell.me { background: color-mix(in srgb, var(--fail) 18%, transparent); }
.row.result .cell.me { font-weight: 700; }
.hex { font: 12px ui-monospace, monospace; background: var(--step-bg); padding: 0 4px; border-radius: 4px; cursor: pointer; }
.hex.open { word-break: break-all; }
.hide-detail .row.detail { display: none; }
.only-checks .row:not(.pass):not(.fail):not(.step):not(.result) { display: none; }
@media (max-width: 900px) { .cards { grid-template-columns: 1fr; } .row, .head { grid-template-columns: 70px 1fr; } .cell, .head div:not(:first-child) { grid-column: 2; } .head .h-backend, .head .h-board-cpp { display: none; } }
</style>
</head>
<body>
<div class="wrap">
<h1>Board enrollment, live</h1>
<p class="sub">A Go backend and two simulated boards (C with libsodium, C++ with OpenSSL) talk through a real Mosquitto broker. Each lane is one program.</p>
<details class="how" open>
<summary>What you are watching</summary>
<ol>
<li>A raw board connects <b>anonymously</b> and may only use its own private reply topic.</li>
<li>It sends a <b>public key</b>; the backend answers with its own public key and a <b>signature</b>. No password is ever sent.</li>
<li>Both sides mix the two public keys into the <b>same secret</b> and turn it into the broker password. Their <b>fingerprints</b> must match.</li>
<li>The board logs in as its new identity (FN) and can only use its own FN topics.</li>
</ol>
</details>
<div class="cards" id="cards"></div>
<div class="now" id="now"><span class="who">Waiting for the programs to start…</span> <span style="color:var(--muted)">(they pause for a countdown first, so the logs can be followed)</span></div>
<div class="tools">
<label><input type="checkbox" id="autoscroll" checked> follow live</label>
<label><input type="checkbox" id="onlychecks"> only checks and steps</label>
<label><input type="checkbox" id="hidedetail"> hide countdown and detail lines</label>
<button id="restart" title="Stops the backend and boards, restarts the broker with a clean state and runs everything again">&#8635; Run again</button>
<span class="status" id="status">connecting…</span>
</div>
<div class="grid" id="grid">
<div class="head"><div>time</div><div class="h-board-c">Board (C, libsodium)</div><div class="h-backend">Backend (Go)</div><div class="h-board-cpp">Board (C++, OpenSSL)</div></div>
</div>
</div>
<script>
// ACTORS: display name, library and colour for each log source (the tag in "[tag]" of a log line).
const ACTORS = {
'board-c': { name: 'Board C', lib: 'libsodium', col: 2, accent: 'var(--c)' },
'backend': { name: 'Backend', lib: 'Go stdlib', col: 3, accent: 'var(--be)' },
'board-cpp': { name: 'Board C++', lib: 'OpenSSL', col: 4, accent: 'var(--cpp)' },
};
// Plain-language explanation shown in the "now" box when a program starts a step.
const BOARD_STEPS = {
1: 'The board invents a random one-time name (Client ID), a secret key pair for key agreement, and a random number (nonce). The private key stays in memory and is never sent.',
2: 'It connects to the broker with no username or password. The broker lets anonymous clients in, but only to the enrollment topics.',
3: 'It subscribes to its own private reply topic. The broker must refuse anyone else\'s topic and any wildcard, so no other board can read this reply.',
4: 'It sends its PUBLIC key, nonce and MAC address to the backend. Nothing secret is in this message.',
5: 'It waits for the backend\'s answer (and re-sends the same request if the answer gets lost).',
6: 'It checks the answer is signed by our backend, using the public key built into its firmware. Tampered, replayed or swapped answers must be rejected.',
7: 'Board key + backend key = the same shared secret on both sides. The board turns it into the broker password locally; compare the fingerprint with the backend\'s.',
8: 'It logs in as its new identity (FN) with the derived password. Wrong password, or the right password from a different Client ID, must be refused.',
9: 'As an FN board it may use only its own topics: it says hello and gets an acknowledgement. Other boards\' topics are refused.',
10: 'Finished: all checks are tallied above.',
};
const BACKEND_STEPS = {
1: 'The backend connects to the broker as the Dynamic Security administrator.',
2: 'It sets up the rules: the anonymous enrollment role, the FN board role, and deny-by-default for everything else.',
3: 'A separate service connection subscribes to enrollment requests and board hellos. (Changing roles of a connected client disconnects it, so admin and service are different clients.)',
4: 'An enrollment request arrived: it assigns an FN, makes its own temporary key, derives the password, creates the broker account with that password, signs the answer and sends it back.',
9: 'A board logged in as its FN and said hello, which proves the derived password and the FN permissions work.',
};
// State kept in the browser: the sorted rows, per-program counters, and the password fingerprints
// seen so far (backend per FN, and per board) which are compared to show "fingerprint matches".
const grid = document.getElementById('grid');
const rows = []; // {time, seq, el}
const stats = {}; // per actor
const fnFp = {}; // FN -> backend fingerprint
const boardFp = {}; // actor -> {fn, fp}
for (const t of ['board-c', 'backend', 'board-cpp']) stats[t] = { step: 0, pass: 0, fail: 0, result: null, last: '' };
// esc: escape text before putting it in HTML. hexify: shorten long hex values (click to expand).
function esc(s) { return s.replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); }
function hexify(s) {
return esc(s).replace(/[0-9a-f]{40,}/g, m =>
`<span class="hex" title="click to expand" data-full="${m}" data-short="${m.slice(0, 10)}…${m.slice(-6)}">${m.slice(0, 10)}…${m.slice(-6)}</span>`);
}
grid.addEventListener('click', e => {
const h = e.target.closest('.hex'); if (!h) return;
const open = h.classList.toggle('open');
h.textContent = open ? h.dataset.full : h.dataset.short;
});
// buildCards/updateCards: the three summary cards (progress bar, checks passed, fingerprint chip).
function buildCards() {
const el = document.getElementById('cards');
el.innerHTML = '';
for (const t of ['board-c', 'backend', 'board-cpp']) {
const a = ACTORS[t];
const d = document.createElement('div');
d.className = 'card'; d.style.setProperty('--accent', a.accent); d.id = 'card-' + t;
d.innerHTML = `<h2>${a.name}</h2><div class="meta">${a.lib}</div>
<div class="bar"><div></div></div><div class="meta" data-k="step">not started</div>
<div class="chips"></div>`;
el.appendChild(d);
}
}
function updateCards() {
for (const t of ['board-c', 'backend', 'board-cpp']) {
const s = stats[t], c = document.getElementById('card-' + t);
const total = t === 'backend' ? 0 : 10;
c.querySelector('.bar > div').style.width = total ? Math.min(100, s.step / total * 100) + '%' : (s.step ? '100%' : '0');
c.querySelector('[data-k=step]').textContent = s.step ? (t === 'backend' ? `last step ${s.step}` : `step ${Math.min(s.step, 10)} of 10`) : 'not started';
const chips = [];
if (t !== 'backend') {
chips.push(`<span class="chip ok">${s.pass} checks passed</span>`);
if (s.fail) chips.push(`<span class="chip bad">${s.fail} failed</span>`);
const f = boardFp[t];
if (f && fnFp[f.fn]) chips.push(f.fp === fnFp[f.fn]
? `<span class="chip ok">password fingerprint matches backend (${f.fp})</span>`
: `<span class="chip bad">fingerprint MISMATCH</span>`);
if (s.result) chips.push(`<span class="chip ${s.result.startsWith('PASS') ? 'ok' : 'bad'}">${esc(s.result)}</span>`);
} else {
chips.push(`<span class="chip">${Object.keys(fnFp).length} FN account(s) created</span>`);
}
c.querySelector('.chips').innerHTML = chips.join('');
}
}
// setNow: update the explanation box for the latest step.
function setNow(tag, n, title) {
const a = ACTORS[tag];
const text = (tag === 'backend' ? BACKEND_STEPS : BOARD_STEPS)[n] || title;
const now = document.getElementById('now');
now.style.setProperty('--accent', a.accent);
now.innerHTML = `<span class="who">${a.name} · step ${n}:</span> ${esc(text)}`;
}
// add: handle one log line from the server. It classifies the line (step header, PASS, FAIL, result,
// detail), updates counters and fingerprints, and inserts a row into the right lane in time order.
function add(l) {
const a = ACTORS[l.tag]; if (!a) return;
const st = stats[l.tag];
const el = document.createElement('div');
el.className = 'row'; el.dataset.tag = l.tag;
const msg = l.msg;
let cls = '', body = msg;
const sm = msg.match(/^---- step (\d+): (.*) ----$/);
if (sm) {
cls = 'step'; body = `Step ${sm[1]}: ${sm[2]}`;
st.step = Math.max(st.step, +sm[1]);
setNow(l.tag, +sm[1], sm[2]);
} else if (/^PASS /.test(msg)) { cls = 'pass'; body = msg.replace(/^PASS\s+/, ''); st.pass++; }
else if (/^FAIL /.test(msg)) { cls = 'fail'; body = msg.replace(/^FAIL\s+/, ''); st.fail++; }
else if (/^RESULT:/.test(msg)) { cls = 'result'; st.result = msg.replace('RESULT: ', ''); }
else if (/^(starting in|board starting|backend starting|broker not reachable)/.test(msg)) cls = 'detail';
const fm = msg.match(/derived password for (FN-\d+) locally; fingerprint=([0-9a-f]+)/);
if (fm) boardFp[l.tag] = { fn: fm[1], fp: fm[2] };
const bm = msg.match(/info="[^"]*\|(FN-\d+)"\); fingerprint=([0-9a-f]+)/);
if (bm) fnFp[bm[1]] = bm[2];
el.className = 'row ' + cls;
let html = `<div class="t">${l.time}</div>`;
for (const t of ['board-c', 'backend', 'board-cpp']) {
html += t === l.tag ? `<div class="cell me"><span class="msg">${hexify(body)}</span></div>` : `<div class="cell empty"></div>`;
}
el.innerHTML = html;
let i = rows.length;
while (i > 0 && rows[i - 1].time > l.time) i--;
rows.splice(i, 0, { time: l.time, el });
if (i === rows.length - 1) grid.appendChild(el); else grid.insertBefore(el, rows[i + 1].el);
updateCards();
if (document.getElementById('autoscroll').checked) el.scrollIntoView({ block: 'end', behavior: 'smooth' });
}
for (const [id, cls] of [['onlychecks', 'only-checks'], ['hidedetail', 'hide-detail']])
document.getElementById(id).addEventListener('change', e => document.body.classList.toggle(cls, e.target.checked));
// Start: draw the cards, then open the live stream. The server replays all history first, so a page
// refresh shows the whole run again.
// Run again: ask the viewer server to restart the whole demo (backend, boards and a fresh broker).
// The custom header is required by the server so other web pages cannot trigger it.
document.getElementById('restart').addEventListener('click', async () => {
document.getElementById('restart').disabled = true;
try {
const r = await fetch('/api/restart', { method: 'POST', headers: { 'X-Demo-Action': 'restart' } });
if (!r.ok) document.getElementById('status').textContent = 'restart refused: ' + (await r.text()).trim();
} catch (e) { document.getElementById('status').textContent = 'restart failed: ' + e; }
});
// resetView: forget everything shown so far (the server tells us when a new run starts).
function resetView() {
grid.querySelectorAll('.row').forEach(r => r.remove());
rows.length = 0;
for (const k of Object.keys(fnFp)) delete fnFp[k];
for (const k of Object.keys(boardFp)) delete boardFp[k];
for (const t of Object.keys(stats)) stats[t] = { step: 0, pass: 0, fail: 0, result: null, last: '' };
document.getElementById('now').innerHTML = '<span class="who">New run starting…</span> <span style="color:var(--muted)">(the programs pause for a countdown first)</span>';
updateCards();
}
// The restart button only exists when the server was started with restart support enabled.
fetch('/api/config').then(r => r.json()).then(c => { if (!c.restart) document.getElementById('restart').style.display = 'none'; })
.catch(() => { document.getElementById('restart').style.display = 'none'; });
buildCards();
const es = new EventSource('/events');
let liveText = 'connected, live';
es.onopen = () => document.getElementById('status').textContent = liveText;
es.onerror = () => document.getElementById('status').textContent = 'reconnecting…';
es.onmessage = e => add(JSON.parse(e.data));
es.addEventListener('reset', resetView);
es.addEventListener('status', e => {
const s = JSON.parse(e.data);
document.getElementById('restart').disabled = s.busy;
document.getElementById('status').textContent = s.busy || s.text !== 'ready' ? s.text : liveText;
});
</script>
</body>
</html>
+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)
}