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>
289 lines
17 KiB
HTML
289 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">↻ 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 => ({ '&': '&', '<': '<', '>': '>' }[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.
|
|
let cfg = { restart: false, restartUrl: '' };
|
|
document.getElementById('restart').addEventListener('click', async () => {
|
|
// Public deployment: no Docker access here, so send the user to Gitea, which requires a login.
|
|
if (!cfg.restart && cfg.restartUrl) { window.open(cfg.restartUrl, '_blank', 'noopener'); return; }
|
|
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 => {
|
|
cfg = c;
|
|
const b = document.getElementById('restart');
|
|
if (c.restart) return;
|
|
if (c.restartUrl) { b.innerHTML = '↻ Run again (via Gitea)'; b.title = 'Opens Gitea, where you run the "Restart demo" workflow (login required)'; }
|
|
else b.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);
|
|
if (cfg.restart) document.getElementById('restart').disabled = s.busy;
|
|
document.getElementById('status').textContent = s.busy || s.text !== 'ready' ? s.text : liveText;
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|