Files
mqtt-security-test/docker/viewer/index.html
T
RyanandClaude Sonnet 5.5 bf40b92c52
Test and deploy enrollment demo / test (push) Successful in 7s
Test and deploy enrollment demo / deploy (push) Successful in 1m12s
Add MQTT enrollment demo with Gitea deploy pipeline
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>
2026-10-24 21:25:14 +03:30

280 lines
17 KiB
HTML

<!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>