diff --git a/.gitignore b/.gitignore index dbfce05..5493f2e 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ # The control server binary, if you build it outside Docker lab/control/control + +# A recording from `make record`, under its default name. A file somebody +# meant to keep gets a name of its own. +lab/lab.jsonl diff --git a/lab/Makefile b/lab/Makefile index c698840..9df74be 100644 --- a/lab/Makefile +++ b/lab/Makefile @@ -19,7 +19,7 @@ URL ?= http://localhost:8099 .DEFAULT_GOAL := help .PHONY: help up attack down reset logs shell status audit check ui hooks \ - day-one typical weak hardened + record day-one typical weak hardened help: @printf 'The lab — real containers, real WireGuard, real packets.\n\n' @@ -27,6 +27,7 @@ help: @printf ' make attack add evil-box (never starts on its own)\n' @printf ' make status tailscale status from the laptop\n' @printf ' make audit run the eleven checks and print the score\n' + @printf ' make record write the wire to lab.jsonl — watch it back in replay.html\n' @printf ' make check licences, links, go vet, go test, gofmt, and the UI JavaScript\n' @printf ' make ui parse-check ui/ and assets/, run the board rules\n' @printf ' make hooks install the pre-push hook that runs make check\n' @@ -87,6 +88,42 @@ M ?= lab-vps shell: $(COMPOSE) exec $(M) bash +# A recording of the wire, with no browser in it. +# +# The wire is SSE, so what comes down it is an `event:` line, a `data:` line +# and a blank one. `sed -n 's/^data: //p'` keeps the middle one, and what is +# left is one JSON object per line — a `.jsonl` file, which is what +# lab/control/ui/replay.html reads. The recording is complete on its own: the +# first frame carries the machines, the attacks and the themes, so playing it +# back needs no lab and no server. +# +# make record until you press Ctrl-C +# make record FOR=90 ninety seconds, then stop by itself +# make record FILE=stolen-key.jsonl +# +# Ctrl-C is the ordinary ending and it can cut the last line in half. The +# player counts that as one skipped line and plays the rest, which is why +# there is no cleanup step here. +# +# It records what the wire carries by default, which is everything except the +# two sources that cost a process inside a container. For a capture, ask for +# it in the URL — the parameters are documented at the top of +# lab/control/feed.go, and a recording's URL is the honest record of what was +# being watched: +# +# make record STREAM='/api/stream/hud?capture=evil-box' +FILE ?= lab.jsonl +FOR ?= +STREAM ?= /api/stream/hud +record: + @printf 'Recording %s from %s%s.\n' '$(FILE)' '$(URL)' '$(STREAM)' + @if [ -n '$(FOR)' ]; then printf 'Stopping by itself after %s seconds.\n\n' '$(FOR)'; \ + else printf 'Drive the lab, then press Ctrl-C.\n\n'; fi + @curl -sN $(if $(FOR),--max-time $(FOR),) '$(URL)$(STREAM)' \ + | sed -n 's/^data: //p' > '$(FILE)' || true + @printf '%s frames in %s.\n' "$$(wc -l < '$(FILE)' | tr -d ' ')" '$(FILE)' + @printf 'Watch it: open %s/replay.html and drop the file on the page.\n' '$(URL)' + audit: @curl -fsS $(URL)/api/audit | \ python3 -c 'import json,sys; r=json.load(sys.stdin); \ diff --git a/lab/README.md b/lab/README.md index 3792143..547412b 100644 --- a/lab/README.md +++ b/lab/README.md @@ -1119,6 +1119,7 @@ make up # build and start, then wait for the three machines to join make attack # add evil-box — it never starts on its own make status # tailscale status and netcheck, from the laptop make audit # the eleven checks, scored, from the command line +make record # write the wire to lab.jsonl, to watch back in replay.html make check # Go and JavaScript — the one target that runs with the lab down make ui # just the JavaScript half: parse-check ui/, run the board rules make hooks # install the pre-push hook that runs make check for you @@ -1256,7 +1257,8 @@ lab/ ├── checks/ the JavaScript half of `make check` — no npm, no packages │ ├── syntax.mjs node --check over every file the browser loads │ ├── reading.test.mjs the board rules, asserted without a browser -│ └── setpieces.test.mjs that hud/ links, and that no action lost its shot +│ ├── setpieces.test.mjs that hud/ links, and that no action lost its shot +│ └── replay.test.mjs that a recording plays back as the thing recorded ├── config/headscale/ coordination server configuration, commented ├── images/ │ ├── node/ a lab machine: tailscaled, tailcat, sshd, ufw, mosh @@ -1278,8 +1280,12 @@ lab/ ├── *_test.go fixtures captured from a live lab; no containers needed └── ui/ the same vocabulary as chapter 14 ├── index.html the shell: both drawings, the two tab strips, the guide + ├── wire.js what the wire carries — the only copy of that list ├── feed.js one EventSource, shared by everything that reads the lab ├── app.js a classic script, the same shape as assets/sandbox.js + ├── replay.html the player: a recording, with no lab behind it + ├── replay.js both halves — record the wire, and play a file back + ├── replay-app.js the player's page: a frame becomes a drawing here └── hud/ ├── three.module.js three.js r166, MIT, inside the binary ├── scene.js the board: five gates, three planes, the packet @@ -1326,7 +1332,7 @@ assume: | type | carries | when | |---|---|---| -| `hello` | the type list, what was subscribed, the server's clock | once, at open | +| `hello` | the type list, what was subscribed, the server's clock, and `page` | once, at open | | `state` | the whole `State` snapshot | when it changed | | `status` | `tailscale status`, as text | when it changed | | `netcheck` | `tailscale netcheck`, as text | when it changed | @@ -1356,13 +1362,16 @@ well as returned. The board still draws from the reply, so the feed is not in the drawing path and cannot break it; what the feed adds is that something which *did not press the button* can see what the button did. Which is the whole reason it exists: a captured attack is a recording of this wire, and -`data:` lines are `.jsonl` already. +`data:` lines are `.jsonl` already. [Record and replay](#record-and-replay) is +what that turned into. -```bash -# One attack, as a file. Play it back later with no Docker at all. -curl -sN 'http://127.0.0.1:8099/api/stream/hud?capture=evil-box' \ - | sed -n 's/^data: //p' > attack.jsonl -``` +The opening `hello` also carries `page` — the payload `/api/meta` serves, the +theme store and the widget registry, the three things the page is made of. That +looks like duplication and is the opposite: it is what makes a recording +complete. The thing that plays one back has no server to ask, so a file +carrying only events could not say which machines to cut doorways for, which +actions have a set-piece, or where the cards go. The first line has to be enough +on its own. A reader that stops reading loses events rather than holding the lab up — `/api/action` publishes from inside the request, so a send that blocked would be @@ -1499,6 +1508,90 @@ fails if a selector reaches wider — the same test, and the same reason, as the one holding a theme to the nine `--board-*` properties. Below 1000px the cards stop floating and stack at the foot of the page, which is what the rails do. +### Record and replay + +One wire was worth building for two readers. The first is a widget nobody has +written yet. The second is a file: + +```bash +make record # until you press Ctrl-C +make record FOR=90 # ninety seconds, then it stops by itself +make record FILE=stolen-key.jsonl STREAM='/api/stream/hud?capture=evil-box' +``` + +That writes one JSON object per line — the frames from the wire, with the SSE +framing stripped. To watch one, open **[`/replay.html`](control/ui/replay.html)** +and drop the file on the page. + +``` +http://localhost:8099/replay.html +``` + +The live page has a **record** button in the bar, which is the other half of +the same thing and usually the one you want. It opens no connection and starts +no process in any container: the frames are already arriving, and it writes +down the ones that do. So a recording is exactly what the page saw — including +the capture you had switched on, and not including the one you had not. It +opens with the board you were looking at rather than with three seconds of an +empty one, because the snapshots the page is already drawn from go in first. +Pressing stop hands you the file. + +**The player needs an HTTP origin and does not need a lab.** Nothing on that +page asks the control server anything — the recording carries the machines, the +attacks, the themes and the widget registry in its first line, which is what +`page` in the `hello` is for. So this works with every container stopped, and +with Docker not installed: + +```bash +cd lab/control/ui && python3 -m http.server 8000 +# then http://localhost:8000/replay.html +``` + +It cannot be a double-click on the file, and that is the one limit worth +knowing: ` + diff --git a/lab/control/ui/replay-app.js b/lab/control/ui/replay-app.js new file mode 100644 index 0000000..d9715da --- /dev/null +++ b/lab/control/ui/replay-app.js @@ -0,0 +1,599 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + Copyright (C) 2026 Hashem Aldhaheri */ + +/* ============================================================ + The player's page. + + app.js is the control surface: it posts to the API, reads the lab back, and + plays the shot the reply describes. This is the same drawing with all of + that taken out. Nothing here posts anything, because there is nothing to + post to — the recording already happened, and this file's whole job is to + turn a file of frames into the board somebody would have been looking at. + + Which means the mapping from a frame to a drawing is the interesting part, + and it is deliberately the same mapping app.js makes: + + state the configuration, drawn continuously -> board.setState + verdict a Result from /api/action -> the set-piece for its id + a Result from /api/set or /api/preset -> the verdict line only + flow a Result from /api/probe -> board.probe + packet one line of tcpdump -> whatever asked to watch + + `set` and `preset` draw no shot on purpose, and that is not a simplification. + On the live page a switch flip moves the board by changing the lab and + letting the next state snapshot redraw it — the board never renders a switch + as an event. A player that played a shot for every flip would be a player + showing something the reader never saw. + + Everything here survives a page with no WebGL, a recording with no page + payload in it, and a file that is half a recording. What it will not survive + is being opened from `file://`, and nothing can: ` + + + + + diff --git a/lab/control/ui/replay.js b/lab/control/ui/replay.js new file mode 100644 index 0000000..a47cab8 --- /dev/null +++ b/lab/control/ui/replay.js @@ -0,0 +1,645 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + Copyright (C) 2026 Hashem Aldhaheri */ + +/* ============================================================ + Record the wire, and play it back. + + This is the point of putting the lab on one wire. A recording is a file of + the frames that went past — one JSON object per line, `.jsonl` — and this + file is both halves of it: the recorder that writes one from a live feed, + and the player that reads one and hands it out through exactly the surface + the live feed has. So a captured attack is a file somebody can post, and + anybody can watch it with no containers, no Docker and no lab. + + on(type, fn) -> off() the same three calls feed.js has + want(sources) -> release() + status() + + `want` is the one that cannot mean anything here, and it is kept rather + than dropped: whether the packets are in the recording was decided when it + was made, and the hello says which capture was running. A widget written + against the live feed then works on a recording without knowing which one + it has — which is the whole reason the surface is the same shape. + + On top of that surface is a transport, because a recording is a thing you + scrub: + + play() pause() toggle() seek(ms) seekTo(index) next() prev() + setRate(x) onChange(fn) -> off() + + Two things here are less obvious than they look. + + **A scrub is not a fast replay.** The wire's snapshots — state, status, + netcheck, stat — are only sent when they changed, so the picture at any + point in a recording is the last one of each *before* that point, not the + sum of everything since the start. Seeking therefore puts those back and + then plays on, and skips every packet and every verdict in between. Playing + a minute of tcpdump at 60× to arrive at the right board is the alternative, + and it is both slower and wrong: every set-piece it passed through would + fire. + + **The hello is a header, not a moment.** It describes the connection, and + on a recording made from a session already in progress it is older than + everything else in the file. Counting it as the first moment would open + every such recording with a stretch of dead air as long as the session had + been going. So it is emitted the instant it is reached and takes no time at + all, and the clock starts at the first frame that is about the lab. + + A classic script, like feed.js, and for the same reason: the player page + runs it before any module lands. It hands itself over on window.LabReplay. + ============================================================ */ +(function () { + "use strict"; + + var WIRE = window.LabWire; + if (!WIRE) throw new Error("replay.js needs wire.js, loaded before it"); + + function noop() {} + + /* ============================================================ + Reading a file + ============================================================ */ + + /* Parse a recording. Never throws: a file somebody edited by hand, or + truncated by ^C halfway through a line, is still mostly a recording, and + the honest thing is to play the part that parses and say how many lines + did not. A player that refuses the whole file over its last line is a + player that loses the attack you actually captured. + + Returns the shape the player and the page both read: + + events the frames, in file order, each {seq, at, type, data} + offsets ms from the start of the recording, one per event, monotonic + duration ms from the first frame about the lab to the last + hello the first hello's payload, or null + page hello.page: the meta, themes and widgets the board needs + moments the verdicts and flows, with where they are + skipped lines that were not a frame + strays type names that are not in this build's vocabulary */ + function parse(text) { + var raw = String(text == null ? "" : text).split("\n"); + var events = [], skipped = 0, strays = {}; + + for (var n = 0; n < raw.length; n++) { + var line = raw[n].trim(); + if (!line) continue; + + /* `curl`ing the wire into a file is the documented way to make a + recording without a browser, and what that writes is SSE: an + `event:` line, a `data:` line, a blank one. So a file with the + prefixes still on it is a recording rather than a broken one, and + the `event:` lines are dropped without being counted as damage — + the type is inside the frame as well, which is why the envelope + carries it. */ + if (line.slice(0, 6) === "data: ") line = line.slice(6).trim(); + else if (line.charAt(0) !== "{") { + if (line.slice(0, 6) !== "event:" && line.slice(0, 3) !== "id:" && + line.charAt(0) !== ":") skipped++; + continue; + } + + var ev; + try { + ev = JSON.parse(line); + } catch (e) { + skipped++; + continue; + } + if (!ev || typeof ev !== "object" || typeof ev.type !== "string") { + skipped++; + continue; + } + /* A frame with no clock belongs to the moment before it. Hand-written + fixtures are the case — nobody writing one by hand types + milliseconds since 1970 — and treating it as 1970 would put the + whole recording behind a 56-year gap. */ + if (typeof ev.at !== "number" || !isFinite(ev.at)) { + ev.at = events.length ? events[events.length - 1].at : 0; + } + if (WIRE.TYPES.indexOf(ev.type) < 0) strays[ev.type] = (strays[ev.type] || 0) + 1; + events.push(ev); + } + + /* The clock starts at the first frame that is about the lab. See the + header: a hello is a header and takes no time. */ + var startAt = 0; + for (var s = 0; s < events.length; s++) { + if (events[s].type !== "hello") { startAt = events[s].at; break; } + startAt = events[s].at; + } + + var offsets = [], run = 0; + for (var k = 0; k < events.length; k++) { + if (events[k].type !== "hello") { + /* Monotonic on purpose. Two producers in the server stamp their own + frames and a recording stitched together by hand can be in any + order at all; a timeline that goes backwards is a player that + either hangs or replays. Clamping says "no earlier than the frame + before it", which is the weakest claim that still plays. */ + var off = events[k].at - startAt; + if (off > run) run = off; + } + offsets.push(run); + } + + var hello = null; + for (var h = 0; h < events.length; h++) { + if (events[h].type === "hello") { hello = events[h].data || null; break; } + } + + return { + events: events, + offsets: offsets, + duration: offsets.length ? offsets[offsets.length - 1] : 0, + hello: hello, + page: (hello && hello.page) || null, + moments: momentsIn(events, offsets), + skipped: skipped, + strays: strays + }; + } + + /* The things worth jumping to: every Result the lab returned, which is + every switch flipped, every configuration applied, every attack run and + every probe measured. + + They are the reason the recording exists, and a reader should not have to + find them by dragging a scrubber and watching for the board to move. */ + function momentsIn(events, offsets) { + var out = []; + for (var i = 0; i < events.length; i++) { + var ev = events[i]; + if (ev.type !== "verdict" && ev.type !== "flow") continue; + var d = ev.data || {}; + var res = d.result || {}; + out.push({ + index: i, + at: offsets[i], + type: ev.type, + id: d.id || "", + kind: d.kind || "", + ok: !!res.ok, + danger: !!res.danger, + rung: res.rung || 0, + rule: res.rule || "", + why: res.why || "", + from: res.from || "", + to: res.to || "", + port: res.port || "" + }); + } + return out; + } + + /* The snapshots standing immediately before `index`, in the order a reader + wants them applied: the wire, then the lab. + + Pure, and separated out because it is the one piece of this file whose + being wrong is invisible — a board showing the configuration from two + scrubs ago looks exactly like a board showing the right one. It is tested + in lab/checks/replay.test.mjs for that reason. */ + function prelude(recording, index) { + var events = (recording && recording.events) || []; + var stop = Math.min(index, events.length); + var found = {}; + for (var i = 0; i < stop; i++) { + if (WIRE.isLatching(events[i].type)) found[events[i].type] = events[i]; + } + var out = []; + for (var t = 0; t < WIRE.LATCHING.length; t++) { + var ev = found[WIRE.LATCHING[t]]; + if (ev) out.push(ev); + } + return out; + } + + /* ============================================================ + Writing one + ============================================================ */ + + /* Record a live feed. Starts immediately — there is no arm-then-start, + because the moment worth capturing is usually the one that just + happened. + + It opens no connection and asks the server for nothing. The frames are + already arriving; this listens to the same fan-out every widget listens + to, which means a recording is exactly what the page saw, including the + capture somebody had switched on and not including the one they did not. + + `feed.latest()` is what makes recording a session already in progress + worth doing: the snapshots the page is currently drawn from are written + first, so the recording opens on the board in front of the reader rather + than on an empty one waiting three seconds for the next poll. */ + function createRecorder(feed, options) { + var o = options || {}; + /* Fifty thousand frames is about twelve megabytes of tcpdump, and about + four hours of a quiet lab. Past that this stops rather than growing + until the tab dies, and says so — a recording that silently lost its + ending is worse than a short one that admits where it stops. */ + var cap = o.max > 0 ? o.max : 50000; + + var lines = []; + var full = false; + var startedAt = Date.now(); + + function add(ev) { + if (lines.length >= cap) { full = true; return; } + try { + lines.push(JSON.stringify(ev)); + } catch (e) { + /* A frame that will not stringify is a frame with a cycle in it, + which nothing on this wire has. Dropping one line beats ending + the recording. */ + } + } + + /* The snapshots the page is already drawn from, put on the recording as + its opening frames — and stamped with the moment they were put there, + not the moment they were measured. + + That distinction is the difference between a watchable file and a + useless one. `state`, `status` and `netcheck` are sent only when they + changed, so the last one the page saw can be ten minutes old; writing it + with its own clock makes the recording claim ten minutes and spend all + but the last four seconds of it showing nothing. The frame is being + issued now, so `at` is now. + + Nothing is lost and nothing is invented: when the two differ the + measurement's own time travels as `wasAt`. A consumer that does not know + the field ignores it, which is the envelope's whole promise — and the + one consumer that should care is a reader asking how stale the board was + when somebody pressed record. */ + if (feed && typeof feed.latest === "function") { + var seed = feed.latest(); + for (var i = 0; i < seed.length; i++) add(reissue(seed[i], startedAt)); + } + + var off = feed && typeof feed.on === "function" + ? feed.on("*", function (data, ev) { add(ev); }) + : noop; + + var stopped = false; + + return { + /* Stop listening. The lines are kept, so text() and download() still + work afterwards — which is the ordinary order: press stop, then + save. */ + stop: function () { + if (stopped) return; + stopped = true; + off(); + }, + stopped: function () { return stopped; }, + count: function () { return lines.length; }, + /* True when the cap was reached and frames are being dropped. The page + says so on the button, because this is exactly the kind of thing + nobody notices until they play the file back. */ + full: function () { return full; }, + startedAt: function () { return startedAt; }, + text: function () { return lines.length ? lines.join("\n") + "\n" : ""; }, + + /* Hand the file to the browser. A Blob and a synthetic click, which is + the only way a page with no server behind it can put a file on + somebody's disk — and this page has a server but the file does not + exist on it, because the recording was assembled here from what + arrived here. */ + download: function (name) { + var text = this.text(); + if (!text) return false; + var url = URL.createObjectURL(new Blob([text], { type: "application/x-ndjson" })); + var a = document.createElement("a"); + a.href = url; + a.download = name || defaultName(startedAt); + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + /* Revoked on a turn of its own: revoking it in the same tick as the + click is a race some browsers lose, and the symptom is a download + that silently does not happen. */ + setTimeout(function () { URL.revokeObjectURL(url); }, 10000); + return true; + } + }; + } + + /* One frame, re-issued at `at`. A copy rather than an edit: the object came + off the live feed's fan-out and every widget on the page is holding the + same one. */ + function reissue(ev, at) { + var out = { seq: ev.seq, at: at, type: ev.type, data: ev.data }; + if (typeof ev.at === "number" && ev.at !== at) out.wasAt = ev.at; + return out; + } + + /* lab-20260912-174233.jsonl — sortable, and it says when rather than what, + because what is in the file and a reader renaming it is the normal case. */ + function defaultName(ms) { + var d = new Date(ms || Date.now()); + function two(n) { return (n < 10 ? "0" : "") + n; } + return "lab-" + d.getFullYear() + two(d.getMonth() + 1) + two(d.getDate()) + + "-" + two(d.getHours()) + two(d.getMinutes()) + two(d.getSeconds()) + ".jsonl"; + } + + /* ============================================================ + Playing one + ============================================================ */ + + /* How often the clock is read. Every frame due since the last read is + handed out together, which buckets a burst of tcpdump into 60ms groups — + imperceptible, and it is what keeps this one timer rather than one + timeout per frame with a cancel on every seek. */ + var TICK = 60; + + function now() { + return (window.performance && window.performance.now) + ? window.performance.now() : Date.now(); + } + + function createPlayer(recording) { + var rec = recording && recording.events ? recording : parse(""); + var events = rec.events, offsets = rec.offsets; + + var listeners = {}; + var watchers = []; + + var at = 0; /* ms into the recording */ + var next = 0; /* the first frame not yet handed out */ + var playing = false; + var rate = 1; + var seen = 0; /* frames handed out, for the wire card */ + var seq = 0; + var timer = null; + var clock = 0; + var staged = -1; /* the index the snapshots on screen belong to */ + + /* ---- the feed's surface, unchanged ---------------------------- */ + + function on(type, fn) { + if (typeof fn !== "function") return noop; + (listeners[type] || (listeners[type] = [])).push(fn); + return function () { off(type, fn); }; + } + + function off(type, fn) { + var list = listeners[type]; + if (!list) return; + var i = list.indexOf(fn); + if (i >= 0) list.splice(i, 1); + } + + /* One listener throwing must not stop the ones after it, same as the live + feed: a widget with a bug is a widget with a bug, and it is not a + reason for the board to stop being told what is on the wire. */ + function fanOut(type, ev) { + var list = listeners[type]; + if (!list || !list.length) return; + for (var i = 0; i < list.length; i++) { + try { + list[i](ev.data, ev); + } catch (e) { + if (window.console) console.error("replay listener for " + type + ": " + e); + } + } + } + + function emit(ev) { + seen++; + if (typeof ev.seq === "number") seq = ev.seq; + fanOut(ev.type, ev); + fanOut("*", ev); + } + + /* A recording cannot be asked to start a tcpdump. Whether the packets are + in the file was settled when it was made, and hello.capture says which + machine was being listened to. The release function comes back anyway + so a widget that calls it in destroy() — which is every correct one — + works here without knowing which kind of feed it was handed. */ + function want() { return noop; } + + function status() { + var h = rec.hello || {}; + return { + /* There is nothing to be disconnected from, so this is not the live + feed's question. The wire card reads `replay` and says what it is + looking at instead of claiming a connection. */ + open: true, + replay: true, + playing: playing, + href: "", + seq: seq, + seen: seen, + opens: 1, + errors: 0, + /* The server's count, as of when the recording was made. A recording + with a number here has a hole in it, and that is worth knowing + before anybody draws a conclusion from it. */ + dropped: h.dropped || 0, + types: h.types || WIRE.TYPES, + sources: h.sources || null + }; + } + + /* ---- the clock ------------------------------------------------ */ + + function drain() { + while (next < events.length && offsets[next] <= at) { + emit(events[next]); + next++; + } + /* Whatever was just handed out included any snapshot in it, so the + snapshots on screen now belong to this position. Recorded here rather + than only in stage() so that ordinary forward play keeps the note + true — otherwise the first seek after a minute of playing re-applies + a prelude that is already on screen. */ + staged = next; + } + + function step() { + var t = now(); + var target = at + (t - clock) * rate; + clock = t; + + if (target >= rec.duration) { + at = rec.duration; + drain(); + /* The end is a stop, not a loop. A recording that restarts on its own + is one nobody can read the last frame of. */ + pause(); + changed(); + return; + } + at = target; + drain(); + changed(); + } + + function play() { + if (playing) return; + /* Pressing play at the end starts again, because that is what the + button is for at that point and the alternative is a control that + looks live and does nothing. + + seekTo(0) rather than seek(0): the two differ over the frames that + are due at the very instant the recording starts, and on a recording + of one attack that is the attack. */ + if (next >= events.length) seekTo(0); + playing = true; + clock = now(); + timer = setInterval(step, TICK); + changed(); + } + + function pause() { + if (timer) clearInterval(timer); + timer = null; + if (!playing) return; + playing = false; + changed(); + } + + /* ---- moving about --------------------------------------------- */ + + /* Put the snapshots that were standing at `index` back on screen. + Skipped when they are already the ones on screen, which is what makes + dragging the scrubber cheap: between two frames of the recording there + is nothing to re-apply, and re-applying a state snapshot means the + board rebuilding every gate it draws. */ + function stage(index) { + if (staged === index) return; + staged = index; + var pre = prelude(rec, index); + for (var i = 0; i < pre.length; i++) emit(pre[i]); + } + + /* The first frame that has not happened yet at `ms`. */ + function indexAfter(ms) { + var i = 0; + while (i < offsets.length && offsets[i] <= ms) i++; + return i; + } + + function seek(ms) { + var target = ms < 0 ? 0 : ms > rec.duration ? rec.duration : ms; + next = indexAfter(target); + at = target; + stage(next); + drain(); + changed(); + } + + /* Seek so that `index` is the next frame to be handed out, and then hand + it out. Used by the moment list, where the point of the jump is the + verdict itself — landing one frame after it would put the board where + the reader asked and never play the shot that got it there. */ + function seekTo(index) { + var i = index < 0 ? 0 : index >= events.length ? events.length - 1 : index; + if (i < 0) return; + next = i; + at = offsets[i]; + stage(i); + drain(); + changed(); + } + + function moment(dir) { + var list = rec.moments; + if (!list.length) return; + if (dir > 0) { + for (var i = 0; i < list.length; i++) { + if (list[i].index >= next) { seekTo(list[i].index); return; } + } + seek(rec.duration); + return; + } + for (var j = list.length - 1; j >= 0; j--) { + /* Strictly before the frame we are about to hand out, or pressing + back on a moment lands on the same one for ever. */ + if (list[j].index < next - 1) { seekTo(list[j].index); return; } + } + seek(0); + } + + function setRate(x) { + var r = Number(x); + if (!isFinite(r) || r <= 0) return; + rate = r; + changed(); + } + + /* ---- telling the page ----------------------------------------- */ + + function onChange(fn) { + if (typeof fn !== "function") return noop; + watchers.push(fn); + return function () { + var i = watchers.indexOf(fn); + if (i >= 0) watchers.splice(i, 1); + }; + } + + function transport() { + return { + playing: playing, + at: at, + duration: rec.duration, + index: next, + count: events.length, + rate: rate, + seen: seen, + done: next >= events.length + }; + } + + function changed() { + var t = transport(); + for (var i = 0; i < watchers.length; i++) { + try { + watchers[i](t); + } catch (e) { + if (window.console) console.error("replay onChange: " + e); + } + } + } + + /* Nothing has been handed out yet, and the first frame is due at zero, so + this puts the header and the opening snapshot on screen without + starting the clock. A player that shows nothing until you press play is + a player that looks broken while it is working. */ + stage(0); + drain(); + + return { + /* the feed's surface */ + on: on, + off: off, + want: want, + status: status, + latest: function () { return prelude(rec, next); }, + + /* the transport */ + play: play, + pause: pause, + toggle: function () { if (playing) pause(); else play(); }, + seek: seek, + seekTo: seekTo, + next: function () { moment(1); }, + prev: function () { moment(-1); }, + setRate: setRate, + transport: transport, + onChange: onChange, + + /* what is being played */ + recording: function () { return rec; }, + close: function () { pause(); listeners = {}; }, + destroy: function () { pause(); listeners = {}; watchers = []; } + }; + } + + window.LabReplay = { + parse: parse, + prelude: prelude, + createRecorder: createRecorder, + createPlayer: createPlayer, + defaultName: defaultName + }; +})(); diff --git a/lab/control/ui/style.css b/lab/control/ui/style.css index 161b21c..26eed98 100644 --- a/lab/control/ui/style.css +++ b/lab/control/ui/style.css @@ -955,3 +955,172 @@ pre.out.live { max-height: 38vh; overflow-y: auto; } .hatch.is-warn b { color: var(--warn); } .hatch.is-bad { background: var(--danger-soft); border-left-color: var(--danger); } .hatch.is-bad b { color: var(--danger); } + +/* --------------------------------------------------------------------------- + Record and replay + + Two pages share this section. On the live page it is one button in the bar: + recording is reading, so it sits with the view controls rather than with + anything that changes the lab. On replay.html it is the transport, the + moment list and the mat that invites a file. + + The transport is positioned from --rail-* like the docks are, and its own + height goes into --pad-* only — so the verdict and the gauges float above + it while the cards stay where the rails put them. Feeding a bar's height + back into the token that positions the bar is a layout that never settles, + which is the same trap the docks documented. + --------------------------------------------------------------------------- */ +/* A deck with one rail in it. `.deck` is `space-between`, which is right for + the live page's pair and puts a lone rail against the left edge — and then + every inset is measured from the wrong side, so the cards, the transport and + the verdict all end up in a column an inch wide over a board with the whole + window free beside it. The player has one rail, and it is on the right. */ +.deck-right { justify-content: flex-end; } + +.recbar { display: flex; align-items: center; gap: 8px; } + +.recbtn { + font: inherit; font-size: 12.5px; font-family: var(--mono); + padding: 4px 11px; border-radius: 999px; cursor: pointer; + border: 1px solid var(--border-strong); background: none; color: var(--text-muted); +} +.recbtn:hover { border-color: var(--accent); color: var(--accent-text); } +.recbtn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } +/* Recording, it is the one live thing in the bar and says so in the colour + this page already uses for "something is happening to you". */ +.recbtn.on { + border-color: var(--danger); background: var(--danger-soft); color: var(--danger); + font-weight: 600; +} +.recbtn.on::before { + content: "\25CF"; margin-right: 6px; + animation: rec-pulse 1.6s ease-in-out infinite; +} +@keyframes rec-pulse { 50% { opacity: .25; } } +@media (prefers-reduced-motion: reduce) { + .recbtn.on::before { animation: none; } +} + +.recplay { + font-family: var(--mono); font-size: 12px; color: var(--text-muted); + text-decoration: none; border-bottom: 1px dotted var(--border-strong); +} +.recplay:hover { color: var(--accent-text); border-bottom-color: var(--accent); } + +/* ---- the transport ---- */ +.transport { + position: absolute; z-index: 26; + left: calc(var(--rail-l, 0px) + var(--hud-gap)); + right: calc(var(--rail-r, 0px) + var(--hud-gap)); + /* From --ladder-h and not --rail-b, which is the one thing in this rule + worth being careful about: this bar's own height is part of --rail-b, so + that the docks stop above it. Reading --rail-b here as well would be a bar + that moves up by its own height every time it is measured. */ + bottom: calc(var(--ladder-h, 0px) + var(--hud-gap)); + display: flex; align-items: center; gap: 10px; + padding: 8px 12px; border-radius: var(--radius); +} +.transport[hidden] { display: none; } + +.tbtn { + font: inherit; font-size: 12.5px; font-family: var(--mono); + min-width: 58px; padding: 5px 10px; border-radius: 7px; cursor: pointer; + border: 1px solid var(--border-strong); background: none; color: var(--text); +} +.tbtn:hover { border-color: var(--accent); color: var(--accent-text); } +.tbtn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } + +/* The scrubber is the whole width that is left, because the thing a reader + does most on this page is move about in the file. */ +.tscrub { flex: 1 1 auto; min-width: 80px; accent-color: var(--accent); } +.tscrub:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +.ttime { color: var(--text-muted); white-space: pre; } + +.trate { display: flex; align-items: center; gap: 6px; } +.trate > span { + font-family: var(--mono); font-size: 10.5px; letter-spacing: .06em; + text-transform: uppercase; color: var(--text-faint); +} +.trate select { + font: inherit; font-size: 12.5px; font-family: var(--mono); + padding: 4px 8px; border-radius: 7px; + border: 1px solid var(--border-strong); background: var(--bg-raised); color: var(--text); +} + +/* ---- what is in the file ---- */ +.col-body .note { font-size: 13px; line-height: 1.55; color: var(--text-muted); margin: 0 0 10px; } +.col-body .note code { font-family: var(--mono); font-size: 12px; } + +.facts { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 3px 10px; } +.facts-k { + font-family: var(--mono); font-size: 10.5px; letter-spacing: .04em; + text-transform: uppercase; color: var(--text-faint); align-self: baseline; +} +.facts-v { font-family: var(--mono); font-size: 12px; color: var(--text); overflow-wrap: anywhere; } +.facts-v.is-bad { color: var(--danger); font-weight: 600; } + +/* ---- the moments ---- + One button per Result the lab returned. They are the reason the recording + exists, so they are a list you can press rather than something to find by + dragging the scrubber and watching for the board to move. */ +.moment { + display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 1px 8px; + width: 100%; text-align: left; font: inherit; cursor: pointer; + margin-top: 6px; padding: 7px 9px; border-radius: 8px; + border: 1px solid var(--border); border-left-width: 3px; + background: var(--bg-sunken); color: var(--text); +} +.moment:hover { border-color: var(--accent); } +.moment:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } +/* Where the reader currently is. The list says that as well as where they + could go, because a scrubber alone does not answer "which one is this". */ +.moment.on { background: var(--bg-raised); border-color: var(--accent-text); } +.moment.ok { border-left-color: var(--ok); } +.moment.warn { border-left-color: var(--warn); } +.moment.bad { border-left-color: var(--danger); } + +.moment .m-at { grid-row: 1 / span 2; color: var(--text-faint); align-self: start; } +.moment .m-head { font-size: 13px; font-weight: 600; overflow-wrap: anywhere; } +.moment .m-kind { font-size: 10.5px; color: var(--text-faint); } +.moment .m-why { + grid-column: 2; font-size: 12px; line-height: 1.5; color: var(--text-muted); + display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; + overflow: hidden; +} + +/* ---- the mat ---- + It covers the board while nothing is open. An empty board with a small note + under it reads as a page that failed; this reads as a page waiting. */ +.dropmat { + position: absolute; z-index: 28; inset: var(--bar-h) 0 0 0; + display: grid; place-items: center; padding: 20px; + background: color-mix(in srgb, var(--bg) 72%, transparent); +} +.dropmat[hidden] { display: none; } +.dropcard { + max-width: 440px; padding: 20px 22px; border-radius: var(--radius); + text-align: center; +} +.dropcard b { display: block; font-size: 16px; margin-bottom: 8px; } +.dropcard p { font-size: 13px; line-height: 1.6; color: var(--text-muted); margin: 0 0 14px; } +.dropcard code { font-family: var(--mono); font-size: 12px; } +.dropcard .btn { width: auto; } + +/* A file over the window. The mat comes back even when a recording is already + open, because the drop will replace it and saying so beforehand is cheaper + than surprising somebody who was aiming at another tab. */ +html.dragging-file .dropmat { display: grid; } +html.dragging-file .dropcard { border-color: var(--accent); } + +@media (max-width: 1000px), (max-height: 620px) { + /* The bar is sticky here and the board is a band at the top, so a transport + floating over the window has nothing to float on. It becomes an ordinary + block under the drawing. */ + .transport { + position: static; inset: auto; border-radius: 0; + border-width: 1px 0; box-shadow: none; backdrop-filter: none; + background: var(--bg-raised); flex-wrap: wrap; + } + .dropmat { position: static; inset: auto; background: none; padding: 16px; } +} diff --git a/lab/control/ui/wire.js b/lab/control/ui/wire.js new file mode 100644 index 0000000..ac51e4f --- /dev/null +++ b/lab/control/ui/wire.js @@ -0,0 +1,58 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + Copyright (C) 2026 Hashem Aldhaheri */ + +/* ============================================================ + The wire's vocabulary — two lists, and the only copy of either. + + feed.js reads them to know what to listen for on a connection it has not + been greeted on yet. replay.js reads them to know which frames a recording + has to open with, and which ones a scrub backwards has to put back. Neither + page can be told by the other: the live page has a server and the player + does not, so a second copy of LATCHING was the alternative, and a stale copy + of it is a player that draws yesterday's board after every scrub with + nothing anywhere saying why. + + `FeedTypes` in lab/control/feed.go is the authority and this is not trying + to be. The server's list arrives in the opening `hello` and feed.js uses + that from the next connection onwards. TYPES here is what a page has to + guess with before it has been greeted, and the list this build shipped with + is the only honest guess available. + + A classic script, loaded before feed.js and before replay.js, because those + are classic scripts too and neither page has a build step to resolve an + import with. + ============================================================ */ +(function () { + "use strict"; + + /* Every type the wire carries. Mirrors FeedTypes in feed.go; a Go test + pins that list, and the player reports a type it was not expecting + rather than dropping it silently. */ + var TYPES = [ + "hello", "state", "status", "netcheck", "stat", + "verdict", "flow", "packet", "log", "note" + ]; + + /* The types where only the most recent one matters, in the order a reader + wants them: what the wire is, then what the lab is. + + These are the wire's snapshots rather than its lines. `state`, `status` + and `netcheck` are sent only when they changed — three seconds of an + unchanged `tailscale status` is not news — so the last one of each is the + current answer and every one before it is history. Everything else is a + moment: a packet, a log line, a verdict. There is no "last packet" that + describes anything. + + Two things need the distinction, and both are in replay.js. A recorder + that starts mid-session has missed the snapshots, and without them writes + a recording that opens on an empty board. A player scrubbed backwards has + to re-apply the snapshot that was standing at that point, rather than + replay every packet since the beginning to get there. */ + var LATCHING = ["hello", "state", "status", "netcheck", "stat"]; + + window.LabWire = { + TYPES: TYPES, + LATCHING: LATCHING, + isLatching: function (type) { return LATCHING.indexOf(type) >= 0; } + }; +})();