Repository navigation
Conversation
The live half of this server was four SSE endpoints — status, tcpdump, logs,
stats — and each consumer opened the one it wanted and parsed it for itself.
Four places that know how to read the lab, which is four things a widget
somebody else writes has to get right before it can draw anything, and four
files a recording would have to be stitched out of.
Now there is /api/stream/hud and one envelope: {seq, at, type, data}. Ten types,
listed in an opening hello so a consumer can ask rather than assume. Seq and the
timestamp are both there for replay — the order, and how long apart — and every
data: line is complete on its own, so a `.jsonl` of them is the recording with
no framing to reconstruct.
Two things carried that were not on a stream before. Every Result the API
returns is published as `verdict` (action, set, preset) or `flow` (probe), with
the id of what ran travelling beside it, because the set-piece a replay has to
pick is chosen by id and cannot be inferred from a Result's shape. The board
still draws from the POST reply, so the feed is not in the drawing path and
cannot break it; what it adds is that something which did not press the button
can see what the button did.
The two sources that cost a process inside a container are asked for in the URL
rather than turned on by a POST — ?capture=evil-box, ?logs=lab-ubuntu. No
subscription state on the server, and a recording whose URL says what was being
watched.
A note now says which source it came from. On four streams it could only have
come from the stream it arrived on; on one wire, a capture pane headed
`tail -f /var/log/tailscaled.log` is what dropping that field gets you.
On the page, ui/feed.js owns the one EventSource and the four call sites that
used to open their own now share it: on() to listen, want() to ask for an
expensive source — reference counted, so two things wanting the capture is one
capture — and status() for what the connection is doing. A classic script loaded
before app.js, because the readouts read the lab from their first line.
Nothing looks different. Measured against the running lab: 904 events on one
wire with no gaps in seq, the sniff verdict and a probe's flow both landing on
it, each text pane getting its own banner and not the other's, two toggles in
one turn costing one reconnect, and no console errors.
This was referenced Sep 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Target
develop: the normal case for a fix, a chapter, alab change, a typo
develop→main, shipping to the published guidemain, because published advice iscurrently locking readers out or leaving them exposed
What this changes
The live half of this server was four SSE endpoints —
status,tcpdump,logs,stats— and each consumer opened the one it wanted and parsed it foritself. Now there is one,
/api/stream/hud, carrying every live thing in oneenvelope:
{seq, at, type, data}. Ten types, listed in an openinghelloso aconsumer can ask rather than assume.
Two things are on it that were never on a stream. Every
Resultthe API returnsis published as
verdict(action, set, preset) orflow(probe), with the id ofwhat ran travelling beside it. And
notenow says which source it came from.No issue — this was asked for directly. It is the second of a planned series;
what is deliberately not in it is listed at the bottom.
Kind
If this changes advice or a command
Not advice, but it removes four endpoints and adds one, so everything below was
run rather than assumed.
What I ran, and what it printed.
The four are gone and the one answers.
One wire, with a real attack on it. Recording
?capture=evil-boxwhilePOST /api/action {"id":"sniff"}ran from outside the recorder:A probe on the same wire arrives as
flow, notverdict:Every command the README now documents. All four, verbatim:
In the page, driving it. Headless Chromium, the real buttons:
endpoints were named in two places,
lab/README.md's file tree and theAPI table in
lab/design/five-gates.html; both moved with thislab/README.mdgains aThe feed section: the envelope, the ten types, what is always on and
what is asked for, the drop policy, and the
.jsonlrecipeChecks
make -C lab checkpasses — "Everything held."gofmtis clean (make checkfails on any output at all)SPDX-License-Identifierheader —feed.go,feed_test.go,ui/feed.jsui/feed.jsis registered inchecks/syntax.mjs, so it is parse-checkedlike the rest: "All 10 files parse" (it was 9)
Nine new Go tests, none of which need a container: the hub's fan-out, that
unsubscribing twice is safe, that a reader which stops reading loses events
rather than blocking
/api/action, the envelope's field names pinned againsta literal, that a
verdictcarries the id of what ran and the wholeResult,that
FeedTypescovers what the wire emits, thatemitgives up on a cancelledcontext, and that a note says which source it came from.
What those tests deliberately do not claim is anything needing Docker —
whether
tailscale statuscomes back, whether tcpdump finds an interface. Thoseare in the measurements above instead, because a test asserting them would be
asserting something it did not observe.
If this touches the guide's pages
It does not. Everything here is under
lab/.assets/andnav.jsareuntouched.
If this touches the lab or sandbox engine
The engine is untouched: no probe, no attack, no rung and no
Resultchanged.The feed carries
Resultwhole and adds nothing to it — a feed that summarisedit would be a second engine. Ticked because they were checked, not assumed:
one particle per real frame; it now asks the feed for the capture instead
of opening its own EventSource, and the frames are the same frames
sniffabove readsHELDat rung 2, unchangeddangerstill outranksokatis the server's owntime.Now()per event, and itis there so a replay does not have to invent the spacing
pass/fail — and the feed publishes that same
Result, rung includedlab/README.mdmoved with it —no score moved; the 27 board-rule tests pass unchanged
Licence
CC BY-SA 4.0 (guide) or GPL-3.0-or-later (code), per the file I touched
Not in this change
Kept out on purpose, one idea per branch:
manifest rather than by
index.html. This is the branch that unblocks it —window.LabFeedis the seam a widget reads — and it is the next one.attack.jsonlabove is a real recording and nothingyet plays it back. The player, and the question of what a replay is allowed to
drive, is its own branch.
reply. Moving it would put the feed in the drawing path, and there is no
reason to risk that in the branch that introduces the feed.
netcheckis sent every poll. It is diffed likestatusandstate, butits text contains timings that change every run, so the diff never holds and a
recording carries one every three seconds. Pre-existing — the old status
stream did the same — and a real thing to fix, but not by this branch.