From b31508239d6d3a4422092eaa26d82d68c9671a07 Mon Sep 17 00:00:00 2001 From: Hashem Aldhaheri <158606+aenawi@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:57:35 +0400 Subject: [PATCH 1/7] Give the board a palette of its own, so a theme can move it (#77) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The board has never carried colours. readTokens() in hud/scene.js reads nine custom properties off the page and paints from what it read, which is how the light/dark button moves the drawing with it. That was already the whole mechanism a theme needs; what was missing was somewhere for one to stand. So the nine become --board-*, declared once in style.css and each defaulting to the page token it used to name. Nothing about the light/dark path changed: the defaults follow the page into both grounds, because a var() resolves where it is used rather than where it is declared. What is new is that a stylesheet can now redefine them without reaching past the drawing and repainting the panels around it. A theme is a folder under ui/hud/themes/ with theme.json and theme.css in it. themes.go walks the embedded UI at startup and serves what it found at /api/themes, so the picker is populated from the server rather than from a list in the markup — adding a theme is a folder and a rebuild, and nothing else is edited. That is the same shape panels.go already has. Two themes ship. `chapter` overrides nothing and is the board exactly as it was; a test fails if it ever starts to, because a second copy of those nine colours is the copy that goes stale. `warroom` is amber on a dark ground, and exists so the seam is demonstrated rather than asserted. A theme that does not parse, or whose id disagrees with its folder, is skipped with a line at the console and the rest of the store still loads — the choice missing() already makes for set-pieces, for the same reason. theme.json's `ground` is advisory: selecting a theme drawn for dark moves the page there, and the light/dark button still wins afterwards. Measured against the running lab on 2026-09-12: /api/themes serves both, both stylesheets are served, selecting warroom moves --board-accent from #0d7a6f to #e8a33d while --accent is untouched, pressing the theme button afterwards changes the ground and leaves the palette, and selecting chapter again leaves --board-accent and --accent identical. No console errors. --- lab/README.md | 57 +++++- lab/checks/syntax.mjs | 1 + lab/control/main.go | 19 ++ lab/control/themes.go | 134 +++++++++++++ lab/control/themes_test.go | 187 +++++++++++++++++++ lab/control/ui/app.js | 39 ++++ lab/control/ui/hud/scene.js | 24 ++- lab/control/ui/hud/themes.js | 145 ++++++++++++++ lab/control/ui/hud/themes/chapter/theme.css | 23 +++ lab/control/ui/hud/themes/chapter/theme.json | 7 + lab/control/ui/hud/themes/warroom/theme.css | 27 +++ lab/control/ui/hud/themes/warroom/theme.json | 7 + lab/control/ui/index.html | 14 +- lab/control/ui/style.css | 42 +++++ 14 files changed, 715 insertions(+), 11 deletions(-) create mode 100644 lab/control/themes.go create mode 100644 lab/control/themes_test.go create mode 100644 lab/control/ui/hud/themes.js create mode 100644 lab/control/ui/hud/themes/chapter/theme.css create mode 100644 lab/control/ui/hud/themes/chapter/theme.json create mode 100644 lab/control/ui/hud/themes/warroom/theme.css create mode 100644 lab/control/ui/hud/themes/warroom/theme.json diff --git a/lab/README.md b/lab/README.md index 00a2c2b..5e7d26d 100644 --- a/lab/README.md +++ b/lab/README.md @@ -1282,9 +1282,64 @@ lab/ ├── three.module.js three.js r166, MIT, inside the binary ├── scene.js the board: five gates, three planes, the packet ├── reading.js what a Result says — pure, and the only tested part - └── setpieces.js one exported function per attack id + ├── setpieces.js one exported function per attack id + ├── themes.js the theme store: what exists, and which is selected + └── themes/ one folder per palette — see below + ├── chapter/ the guide's own colours; overrides nothing + └── warroom/ amber on near-black ``` +### Themes for the board + +The board has never carried a palette. `readTokens()` in `hud/scene.js` reads +nine CSS custom properties off the page and paints from what it read, which is +how the light/dark button moves the drawing with it. A theme is that mechanism, +used deliberately: redefine some of the nine, and tell the board they moved. + +One theme is one folder under `control/ui/hud/themes/`: + +``` +themes/warroom/ +├── theme.json id, name, note, ground, author +└── theme.css the nine, scoped to :root[data-hud-theme="warroom"] +``` + +`themes.go` walks the embedded UI at startup and serves what it found at +`/api/themes`, so the picker in the header is populated from the server rather +than from a list in the markup. Adding a theme is a folder and a rebuild. +Nothing else is edited. + +The nine are declared in `style.css` under **the board's palette**, each +defaulting to the page token it replaced: + +| Property | What it paints | +|---|---| +| `--board-accent` | the tailnet, the gate posts, anything the lab owns | +| `--board-danger` | a rule that refused, and the attacker | +| `--board-warn` | an outcome that proved nothing either way | +| `--board-ok` | a rung that held | +| `--board-line` | gate posts, the ground grid, the rails | +| `--board-text` | machine labels | +| `--board-muted` | secondary labels | +| `--board-faint` | addresses, and anything standing down | +| `--board-card` | the plane the machines stand on | + +Those nine are the whole surface. A theme that sets anything else reaches past +the drawing and repaints the panels around it, which is not what a board theme +is for — and `hud/themes/chapter` is the proof the seam is real: it overrides +nothing, and a test fails if it ever starts to. + +`ground` in `theme.json` is `light`, `dark` or `any`. Selecting a theme that +names one moves the page to it; the light/dark button still wins afterwards, +because a reader who presses it has said something more recent than the +manifest did. + +A theme that does not parse, or whose `id` disagrees with its folder name, is +skipped with a line at the console and the rest of the store still loads. The +same choice `missing()` makes for set-pieces, for the same reason: a broken +extra must not take the page with it, and a silent one is worse than a loud +one. + ### If you edit the UI, rebuild `main.go` does `//go:embed ui`, so the page you get on :8099 is the copy diff --git a/lab/checks/syntax.mjs b/lab/checks/syntax.mjs index e43f5fe..0969bdb 100644 --- a/lab/checks/syntax.mjs +++ b/lab/checks/syntax.mjs @@ -48,6 +48,7 @@ const FILES = [ ["lab/control/ui/hud/scene.js", "module"], ["lab/control/ui/hud/setpieces.js", "module"], ["lab/control/ui/hud/reading.js", "module"], + ["lab/control/ui/hud/themes.js", "module"], ["assets/app.js", "commonjs"], ["assets/anim.js", "commonjs"], ["assets/nav.js", "commonjs"], diff --git a/lab/control/main.go b/lab/control/main.go index d93fc39..0bf8d2e 100644 --- a/lab/control/main.go +++ b/lab/control/main.go @@ -506,6 +506,11 @@ func routes(c *Controller) http.Handler { } mux.Handle("/", assets) + // Walked once, at startup: the themes are in the binary and cannot appear + // while it runs. Anything skipped has already said why at the console by + // the time this returns. + themes := loadThemes(ui) + writeJSON := func(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(v) @@ -544,6 +549,20 @@ func routes(c *Controller) http.Handler { }) }) + // The HUD theme store: which palettes were embedded under ui/hud/themes. + // Separate from /api/meta because it describes the page rather than the + // lab, and because a reader adding a theme should not have to read a + // payload about machines and attacks to find out whether it was picked up. + mux.HandleFunc("/api/themes", func(w http.ResponseWriter, r *http.Request) { + // Never null: the page treats a non-array as an empty store, and an + // empty store is a picker with one option rather than a broken one. + if themes == nil { + writeJSON(w, []Theme{}) + return + } + writeJSON(w, themes) + }) + mux.HandleFunc("/api/state", func(w http.ResponseWriter, r *http.Request) { c.Observe(r.Context()) writeJSON(w, c.Snapshot()) diff --git a/lab/control/themes.go b/lab/control/themes.go new file mode 100644 index 0000000..ab1c897 --- /dev/null +++ b/lab/control/themes.go @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Hashem Aldhaheri + +package main + +import ( + "encoding/json" + "io/fs" + "log" + "path" + "sort" +) + +// --------------------------------------------------------------------------- +// HUD themes +// +// The board has always read its colours off the page rather than carrying a +// palette of its own — readTokens() in hud/scene.js pulls nine custom +// properties and paints from them. That was done so the light/dark button +// could move the board with it, and it is the whole mechanism a theme needs: +// redefine the nine, tell the board they moved. +// +// So a theme is a folder under ui/hud/themes/ with two files in it. theme.css +// redefines whichever of the nine it cares about, scoped to +// :root[data-hud-theme=""] so it is inert until selected. theme.json says +// what it is called and which ground it was drawn for. +// +// This file is the half that finds them. It walks the embedded UI at startup +// so the page does not have to be told what exists — adding a theme is a +// folder and a rebuild, not an edit to a list in three places. Which is the +// same reason panels.go exists: the server holds the spec, the UI renders +// whatever it is handed. +// +// A theme that does not parse, or whose id disagrees with its folder, is +// skipped with a line at the console rather than taken as a reason to refuse +// to start. That is the choice setpieces.go's missing() already makes and for +// the same reason: a broken extra must not take the lab with it, and a silent +// one is how `rotate-key` went a release with nothing drawn. +// --------------------------------------------------------------------------- + +// Theme is one entry in the store, as /api/themes serves it. +type Theme struct { + ID string `json:"id"` + Name string `json:"name"` + Note string `json:"note"` + // Ground is the page background the theme was drawn against: "light", + // "dark", or "any" for one that works on either. It is advisory — selecting + // a theme that names a ground moves the page to it, and the light/dark + // button still wins afterwards. + Ground string `json:"ground"` + Author string `json:"author"` +} + +// themesDir is where the store lives inside the UI filesystem. It is a +// constant rather than a flag because the UI is //go:embed-ed: nothing can put +// a theme anywhere else without rebuilding the binary, which is the point. +const themesDir = "hud/themes" + +// baseTheme is the one that must exist. It overrides nothing — it is the +// guide's own palette, which is what the board looked like before there was a +// store — so it is also the fallback when a saved id no longer resolves. +const baseTheme = "chapter" + +var validGrounds = map[string]bool{"light": true, "dark": true, "any": true} + +// loadThemes walks fsys for ui/hud/themes/*/theme.json and returns what it +// finds, base theme first and the rest alphabetically by name. +// +// fsys is the UI sub-filesystem, so paths here start at hud/. +func loadThemes(fsys fs.FS) []Theme { + entries, err := fs.ReadDir(fsys, themesDir) + if err != nil { + log.Printf("hud themes: %s is not readable (%v), so the store is empty", themesDir, err) + return nil + } + + var out []Theme + for _, e := range entries { + if !e.IsDir() { + continue + } + t, ok := readTheme(fsys, e.Name()) + if !ok { + continue + } + out = append(out, t) + } + + sort.Slice(out, func(i, j int) bool { + if (out[i].ID == baseTheme) != (out[j].ID == baseTheme) { + return out[i].ID == baseTheme + } + return out[i].Name < out[j].Name + }) + return out +} + +// readTheme reads one folder, and says why it skipped it when it does. +// +// The id has to match the folder name because the folder name is what the +// page builds the stylesheet URL from. A theme whose json claimed a different +// id would load one file and select another, and the failure would be a board +// that keeps the previous palette with no error anywhere. +func readTheme(fsys fs.FS, dir string) (Theme, bool) { + b, err := fs.ReadFile(fsys, path.Join(themesDir, dir, "theme.json")) + if err != nil { + log.Printf("hud theme %q: no theme.json, skipped", dir) + return Theme{}, false + } + var t Theme + if err := json.Unmarshal(b, &t); err != nil { + log.Printf("hud theme %q: theme.json does not parse (%v), skipped", dir, err) + return Theme{}, false + } + if t.ID != dir { + log.Printf("hud theme %q: theme.json says id %q, skipped", dir, t.ID) + return Theme{}, false + } + if t.Name == "" { + log.Printf("hud theme %q: no name, skipped", dir) + return Theme{}, false + } + if _, err := fs.Stat(fsys, path.Join(themesDir, dir, "theme.css")); err != nil { + log.Printf("hud theme %q: no theme.css, skipped", dir) + return Theme{}, false + } + if !validGrounds[t.Ground] { + // Not a reason to drop the theme: "any" is the honest reading of a + // theme that did not say, and it is what the base theme is. + log.Printf("hud theme %q: ground %q is not light, dark or any — read as any", dir, t.Ground) + t.Ground = "any" + } + return t, true +} diff --git a/lab/control/themes_test.go b/lab/control/themes_test.go new file mode 100644 index 0000000..a212776 --- /dev/null +++ b/lab/control/themes_test.go @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Hashem Aldhaheri + +package main + +import ( + "io/fs" + "testing" + "testing/fstest" +) + +// The store is read off the embedded UI, so these run against the real +// folders rather than a fixture wherever the question is "is what shipped +// well-formed". A theme added with a typo in its id, or without the +// stylesheet the page will ask for, is exactly the failure this catches — and +// it is silent in a browser, because a stylesheet that 404s leaves the board +// painting the palette it already had. + +func embeddedUI(t *testing.T) fs.FS { + t.Helper() + ui, err := fs.Sub(uiFS, "ui") + if err != nil { + t.Fatalf("embedded UI: %v", err) + } + return ui +} + +func TestEmbeddedThemesAreWellFormed(t *testing.T) { + got := loadThemes(embeddedUI(t)) + if len(got) == 0 { + t.Fatal("no themes were embedded; ui/hud/themes should hold at least " + baseTheme) + } + + seen := map[string]bool{} + for _, th := range got { + if seen[th.ID] { + t.Errorf("theme %q appears twice", th.ID) + } + seen[th.ID] = true + + if th.Name == "" { + t.Errorf("theme %q has no name, so the picker would show an empty row", th.ID) + } + if !validGrounds[th.Ground] { + t.Errorf("theme %q has ground %q, want light, dark or any", th.ID, th.Ground) + } + // The note is what the picker puts in its title attribute. A theme + // with none is not broken, but it is the one thing a reader browsing + // the store has to go on. + if th.Note == "" { + t.Errorf("theme %q has no note", th.ID) + } + } + + if !seen[baseTheme] { + t.Errorf("%q was not found; it is the fallback and has to exist", baseTheme) + } +} + +// The base theme is the one that must override nothing: it is what the board +// looked like before the store, and it is where a saved id falls back to. A +// stylesheet with declarations in it would make "chapter" a palette of its own +// and quietly fork the nine colours away from style.css. +func TestBaseThemeOverridesNothing(t *testing.T) { + b, err := fs.ReadFile(embeddedUI(t), themesDir+"/"+baseTheme+"/theme.css") + if err != nil { + t.Fatalf("reading the base theme: %v", err) + } + if stripComments(string(b)) != "" { + t.Errorf("%s/theme.css declares something; it is meant to override nothing:\n%s", + baseTheme, stripComments(string(b))) + } +} + +// stripComments answers one question about a stylesheet: does it set anything? +// It strips /* ... */ and then looks for a colon inside a block, which is what +// a declaration is and what a selector's pseudo-class is not — :root carries a +// colon too, and it is outside the braces. +// +// It returns the offending text rather than a bool so the failure message can +// show what was found. Empty means the sheet declares nothing. +func stripComments(s string) string { + var out []rune + in := []rune(s) + for i := 0; i < len(in); i++ { + if i+1 < len(in) && in[i] == '/' && in[i+1] == '*' { + for i += 2; i+1 < len(in) && !(in[i] == '*' && in[i+1] == '/'); i++ { + } + i++ + continue + } + out = append(out, in[i]) + } + + depth, start := 0, -1 + for i, r := range out { + switch r { + case '{': + depth++ + case '}': + depth-- + case ':': + if depth > 0 && start < 0 { + start = i + } + } + } + if start < 0 { + return "" + } + return string(out[start:min(start+60, len(out))]) +} + +// A folder whose json disagrees with it would have the page load one +// stylesheet and select a different attribute value, which is a board that +// keeps its old colours and says nothing. Checked against a synthetic tree +// because the embedded one is — and should stay — correct. +func TestThemeIDMustMatchItsFolder(t *testing.T) { + tree := fstest.MapFS{ + themesDir + "/good/theme.json": {Data: []byte(`{"id":"good","name":"Good","ground":"dark"}`)}, + themesDir + "/good/theme.css": {Data: []byte("")}, + themesDir + "/liar/theme.json": {Data: []byte(`{"id":"somethingelse","name":"Liar","ground":"dark"}`)}, + themesDir + "/liar/theme.css": {Data: []byte("")}, + } + got := loadThemes(tree) + if len(got) != 1 || got[0].ID != "good" { + t.Fatalf("got %v, want only the folder whose id matches it", got) + } +} + +func TestThemeWithoutStylesheetIsSkipped(t *testing.T) { + tree := fstest.MapFS{ + themesDir + "/nocss/theme.json": {Data: []byte(`{"id":"nocss","name":"No CSS","ground":"any"}`)}, + } + if got := loadThemes(tree); len(got) != 0 { + t.Fatalf("got %v, want nothing: the page would ask for a theme.css that is not there", got) + } +} + +func TestUnparseableThemeIsSkippedNotFatal(t *testing.T) { + tree := fstest.MapFS{ + themesDir + "/broken/theme.json": {Data: []byte(`{ this is not json`)}, + themesDir + "/broken/theme.css": {Data: []byte("")}, + themesDir + "/fine/theme.json": {Data: []byte(`{"id":"fine","name":"Fine","ground":"any"}`)}, + themesDir + "/fine/theme.css": {Data: []byte("")}, + } + got := loadThemes(tree) + if len(got) != 1 || got[0].ID != "fine" { + t.Fatalf("got %v, want the one good theme: a broken extra must not take the store with it", got) + } +} + +// An unrecognised ground is read as "any" rather than dropping the theme. +// Selecting it then leaves the page's own light/dark setting alone, which is +// the harmless reading. +func TestUnknownGroundIsReadAsAny(t *testing.T) { + tree := fstest.MapFS{ + themesDir + "/odd/theme.json": {Data: []byte(`{"id":"odd","name":"Odd","ground":"chartreuse"}`)}, + themesDir + "/odd/theme.css": {Data: []byte("")}, + } + got := loadThemes(tree) + if len(got) != 1 { + t.Fatalf("got %v, want the theme kept", got) + } + if got[0].Ground != "any" { + t.Errorf("ground = %q, want any", got[0].Ground) + } +} + +// The base theme sorts first because it is the one a reader falls back to and +// the one the picker opens on; the rest are alphabetical by the name shown, +// not by the id, which is what somebody scanning the list is reading. +func TestBaseThemeSortsFirst(t *testing.T) { + tree := fstest.MapFS{ + themesDir + "/aaa/theme.json": {Data: []byte(`{"id":"aaa","name":"Aaa","ground":"any"}`)}, + themesDir + "/aaa/theme.css": {Data: []byte("")}, + themesDir + "/" + baseTheme + "/theme.json": {Data: []byte(`{"id":"` + baseTheme + `","name":"Zzz","ground":"any"}`)}, + themesDir + "/" + baseTheme + "/theme.css": {Data: []byte("")}, + } + got := loadThemes(tree) + if len(got) != 2 { + t.Fatalf("got %v, want two", got) + } + if got[0].ID != baseTheme { + t.Errorf("first = %q, want %q even though its name sorts last", got[0].ID, baseTheme) + } +} diff --git a/lab/control/ui/app.js b/lab/control/ui/app.js index 2afd1f5..b17a4cb 100644 --- a/lab/control/ui/app.js +++ b/lab/control/ui/app.js @@ -37,6 +37,41 @@ if (board) board.refreshTokens(); }); + /* ---- the board's palette --------------------------------------- + A second axis, and not the same one. The button above is the page's + ground; this is which of the embedded HUD themes paints the drawing. + hud/themes.js owns both halves — what exists, and what is selected — + because the server is what knows which folders were embedded. + + The picker is populated from the answer rather than from markup, so a + theme added to hud/themes/ appears here after a rebuild with nothing + else edited. index.html ships one "; + }).join(""); + sel.value = applied; + sel.title = (api.get(applied) || {}).note || ""; + }); + + sel.addEventListener("change", function () { + api.apply(sel.value, board).then(function (applied) { + sel.value = applied; + sel.title = (api.get(applied) || {}).note || ""; + }); + }); + } + /* ---- the board -------------------------------------------------- lab/control/ui/hud is an ES module, because three.js is one. It hands itself over on window.LabHUD and fires an event when it has loaded. @@ -136,6 +171,9 @@ boardBroken = true; $("#view-note").textContent = "the board needs WebGL, and this browser did not give it one"; setView("flat"); + /* Still worth selecting: the picker is the reader's setting, not the + board's, and it has to survive a session that never got a board. */ + startThemes(); return; } if (meta) applyMeta(); @@ -147,6 +185,7 @@ ordinary case: the layout settles long before WebGL does. */ measureRails(); syncZoom(); + startThemes(); } if (window.LabHUD) startBoard(); diff --git a/lab/control/ui/hud/scene.js b/lab/control/ui/hud/scene.js index b0023c6..fdbd1a3 100644 --- a/lab/control/ui/hud/scene.js +++ b/lab/control/ui/hud/scene.js @@ -121,15 +121,21 @@ export function createBoard(canvas, hud) { function readTokens() { const cs = getComputedStyle(document.documentElement); const col = (n) => new THREE.Color(cs.getPropertyValue(n).trim() || "#888"); - C.accent = col("--accent"); - C.danger = col("--danger"); - C.warn = col("--warn"); - C.ok = col("--ok"); - C.line = col("--border-strong"); - C.text = col("--text"); - C.muted = col("--text-muted"); - C.faint = col("--text-faint"); - C.card = col("--bg-raised"); + /* The nine --board-* properties, not the page tokens they used to be. + They are declared in style.css under "the board's palette" and each + defaults to the page token this line used to name, so the light/dark + button still moves the board and nothing about this call changed — + except that a theme under hud/themes/ now has somewhere to stand + between the two. */ + C.accent = col("--board-accent"); + C.danger = col("--board-danger"); + C.warn = col("--board-warn"); + C.ok = col("--board-ok"); + C.line = col("--board-line"); + C.text = col("--board-text"); + C.muted = col("--board-muted"); + C.faint = col("--board-faint"); + C.card = col("--board-card"); } readTokens(); diff --git a/lab/control/ui/hud/themes.js b/lab/control/ui/hud/themes.js new file mode 100644 index 0000000..153d614 --- /dev/null +++ b/lab/control/ui/hud/themes.js @@ -0,0 +1,145 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + Copyright (C) 2026 Hashem Aldhaheri */ + +/* ============================================================ + The HUD theme store. + + A theme is a folder under hud/themes/ holding theme.css and theme.json. + themes.go walks the embedded UI and serves what it found at /api/themes, + so nothing here carries a list: the server knows, and this asks. + + Selecting a theme does three things and no more. + + 1. Make sure the theme's stylesheet is in the document. Every theme's + rules are scoped to :root[data-hud-theme=""], so a sheet that has + been loaded and then deselected is inert rather than removed. Loading + is one-way, which keeps switching back instant and keeps this file + free of the question of when a is safe to drop. + + 2. Set data-hud-theme on the root element, and — if the theme named a + ground — move data-theme to it. Advisory, as themes.go says: the + light/dark button still wins afterwards, because a reader who presses + it has said something more recent than the theme's manifest did. + + 3. Tell the board its colours moved. scene.js reads the nine --board-* + properties once and paints from what it read, so nothing changes on + screen until refreshTokens() is called. This is the same call the + light/dark button already makes. + + Every entry point survives having no board, no server and no localStorage, + because the page it belongs to does. A control surface that will not draw + because a stylesheet 404'd is a worse failure than an unthemed board. + ============================================================ */ + +const BASE = "chapter"; /* matches baseTheme in themes.go */ +const STORE_KEY = "lab-hud-theme"; + +/* Which stylesheets have been put in the document, by id. */ +const loaded = new Set(); + +let catalog = []; +let active = BASE; + +/* ---------- what exists ---------------------------------------- */ + +/* The store, as the server found it. Empty until load() has run, which is + the honest answer before then rather than a guess. */ +export function list() { return catalog.slice(); } + +/* The id currently selected. */ +export function current() { return active; } + +/* One entry by id, or null. */ +export function get(id) { + for (const t of catalog) if (t.id === id) return t; + return null; +} + +/* Ask the server what it embedded. A failure here is not fatal: the base + theme overrides nothing, so a page with no catalog still draws the board + exactly as it did before this file existed. */ +export async function load() { + try { + const res = await fetch("/api/themes", { headers: { Accept: "application/json" } }); + if (!res.ok) throw new Error("HTTP " + res.status); + const body = await res.json(); + catalog = Array.isArray(body) ? body : []; + } catch (e) { + console.warn("hud themes: /api/themes did not answer (" + e.message + "), staying on " + BASE); + catalog = []; + } + return list(); +} + +/* ---------- selecting one -------------------------------------- */ + +/* Put a theme's stylesheet in the document, and resolve when the browser has + parsed it. The wait matters: refreshTokens() reads computed values, and a + board told to repaint before its palette landed paints the old one and has + no reason to try again. + + A sheet that fails to load resolves too. The rules simply never apply, the + board keeps the palette it had, and the console says which file was + missing — which is a readable board and a legible complaint, rather than a + promise that never settles. */ +function ensureSheet(id) { + if (loaded.has(id)) return Promise.resolve(); + return new Promise((resolve) => { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = "hud/themes/" + encodeURIComponent(id) + "/theme.css"; + link.dataset.hudTheme = id; + link.addEventListener("load", () => { loaded.add(id); resolve(); }); + link.addEventListener("error", () => { + console.warn("hud theme " + id + ": theme.css did not load, the board keeps its colours"); + /* Marked loaded on purpose: retrying on every switch would add one dead + per press for as long as the file is missing. */ + loaded.add(id); + resolve(); + }); + document.head.appendChild(link); + }); +} + +/* Select a theme. `board` may be null — the page runs without one whenever + WebGL is unavailable, and the attribute is still worth setting so anything + else styled off it agrees with the picker. */ +export async function apply(id, board) { + const theme = get(id) || get(BASE); + const next = theme ? theme.id : BASE; + + await ensureSheet(next); + document.documentElement.setAttribute("data-hud-theme", next); + + if (theme && (theme.ground === "light" || theme.ground === "dark")) { + document.documentElement.setAttribute("data-theme", theme.ground); + try { localStorage.setItem("lab-theme", theme.ground); } catch (e) { /* private mode */ } + } + + active = next; + try { localStorage.setItem(STORE_KEY, next); } catch (e) { /* private mode */ } + + if (board) board.refreshTokens(); + return next; +} + +/* ---------- starting up ---------------------------------------- */ + +/* Read the catalog, then select whatever was chosen last — falling back to + the base theme when the saved id names a theme that is no longer embedded, + which is what happens the first time somebody removes a folder. + + Returns the id that ended up applied, so a caller can set its picker from + the answer rather than from what it asked for. */ +export async function init(board) { + await load(); + + let saved = null; + try { saved = localStorage.getItem(STORE_KEY); } catch (e) { /* private mode */ } + + if (saved && !get(saved)) { + console.warn("hud themes: " + saved + " is no longer embedded, falling back to " + BASE); + saved = null; + } + return apply(saved || BASE, board); +} diff --git a/lab/control/ui/hud/themes/chapter/theme.css b/lab/control/ui/hud/themes/chapter/theme.css new file mode 100644 index 0000000..acd17a7 --- /dev/null +++ b/lab/control/ui/hud/themes/chapter/theme.css @@ -0,0 +1,23 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + Copyright (C) 2026 Hashem Aldhaheri */ + +/* Chapter — the board as the guide draws it. + + This file overrides nothing, and that is the whole of it. + + The nine --board-* properties in style.css already resolve to the page's + own tokens, so a theme that sets none of them is the palette the board had + before the store existed. Restating them here would be a second copy of + nine colours that the light/dark blocks in style.css already own, and a + second copy is the copy that goes stale — which is the rule the rest of + this repository is built on. + + So: the selector, empty, as the shape every other theme follows. Delete the + rule and nothing changes; it is here so that reading this folder tells you + what a theme is. + + What a theme may set is listed in style.css under "the board's palette". */ + +:root[data-hud-theme="chapter"] { + /* the page's own colours, unchanged */ +} diff --git a/lab/control/ui/hud/themes/chapter/theme.json b/lab/control/ui/hud/themes/chapter/theme.json new file mode 100644 index 0000000..c5195b2 --- /dev/null +++ b/lab/control/ui/hud/themes/chapter/theme.json @@ -0,0 +1,7 @@ +{ + "id": "chapter", + "name": "Chapter", + "note": "The guide's own palette. The board borrows the page's colours and overrides none of them — this is what it looked like before there was a store.", + "ground": "any", + "author": "the guide" +} diff --git a/lab/control/ui/hud/themes/warroom/theme.css b/lab/control/ui/hud/themes/warroom/theme.css new file mode 100644 index 0000000..ee75ba8 --- /dev/null +++ b/lab/control/ui/hud/themes/warroom/theme.css @@ -0,0 +1,27 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + Copyright (C) 2026 Hashem Aldhaheri */ + +/* Warroom — the board as an operations wall. + + Drawn for a dark ground; theme.json says so, and selecting it moves the page + there. Amber carries the accent, which leaves green and red to mean only one + thing each: a rung that held, and a rung that did not. On the Chapter palette + the accent is teal and "ok" is green, which is two greens, and at a glance + across a room they are one. + + The nine properties below are the whole surface a theme has. They are listed + in style.css under "the board's palette"; setting anything else here would + reach past the board and repaint the panels, which is not what a HUD theme + is for. */ + +:root[data-hud-theme="warroom"] { + --board-accent: #e8a33d; /* the tailnet, the gates, anything the lab owns */ + --board-danger: #e2564a; /* a rule that refused, and the attacker */ + --board-warn: #d98b2b; + --board-ok: #5bbf7d; /* a rung that held — the only green on the board */ + --board-line: #3b4652; /* gate posts, the ground grid, rails */ + --board-text: #edf1f5; + --board-muted: #9aa8b6; + --board-faint: #62707e; + --board-card: #12171c; /* the plane the machines stand on */ +} diff --git a/lab/control/ui/hud/themes/warroom/theme.json b/lab/control/ui/hud/themes/warroom/theme.json new file mode 100644 index 0000000..c6d3572 --- /dev/null +++ b/lab/control/ui/hud/themes/warroom/theme.json @@ -0,0 +1,7 @@ +{ + "id": "warroom", + "name": "Warroom", + "note": "An operations wall: amber on near-black, with the verdict colours pushed apart so a denial reads across a room.", + "ground": "dark", + "author": "the guide" +} diff --git a/lab/control/ui/index.html b/lab/control/ui/index.html index 93b84a4..8dfc6a6 100644 --- a/lab/control/ui/index.html +++ b/lab/control/ui/index.html @@ -50,6 +50,17 @@ + + @@ -452,7 +463,8 @@

The disclaimer

diff --git a/lab/control/ui/style.css b/lab/control/ui/style.css index 30c3dba..2561967 100644 --- a/lab/control/ui/style.css +++ b/lab/control/ui/style.css @@ -40,6 +40,32 @@ --radius: 10px; } +/* ---- the board's palette ---- + These nine, and only these nine, are what readTokens() in hud/scene.js + reads and what a HUD theme under hud/themes/ may redefine. They are the + contract: a theme setting anything else reaches past the drawing and + repaints the panels around it, which is not what a board theme is for. + + Each defaults to the page token it used to read directly, so the light and + dark blocks below stay the single home of those nine colours and a theme + that overrides none of them — hud/themes/chapter — is the board exactly as + it was before the store existed. + + They sit outside the theme blocks on purpose. A var() resolves where it is + used, not where it is declared, so one definition here follows the page + into light, into dark, and into whatever a theme does on top. */ +:root { + --board-accent: var(--accent); + --board-danger: var(--danger); + --board-warn: var(--warn); + --board-ok: var(--ok); + --board-line: var(--border-strong); + --board-text: var(--text); + --board-muted: var(--text-muted); + --board-faint: var(--text-faint); + --board-card: var(--bg-raised); +} + @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --bg: #0e1116; --bg-raised: #161b22; --bg-sunken: #0a0d12; --bg-code: #12171f; @@ -494,6 +520,22 @@ pre.out.live { max-height: 38vh; overflow-y: auto; } } .themebtn:hover { border-color: var(--accent); color: var(--accent-text); } +/* The board's palette picker, sitting next to the page's light/dark button and + deliberately quieter than it: one is a setting most readers press once, the + other is a setting most readers never press at all. */ +.hudtheme { + display: inline-flex; align-items: center; gap: 6px; + font-size: 12.5px; color: var(--text-faint); +} +.hudtheme > span { text-transform: uppercase; letter-spacing: .06em; font-size: 10.5px; } +.hudtheme select { + font: inherit; font-size: 12.5px; padding: 3px 8px; border-radius: 999px; + border: 1px solid var(--border); background: var(--bg-sunken); + color: var(--text-muted); cursor: pointer; max-width: 11ch; +} +.hudtheme select:hover { border-color: var(--accent); color: var(--accent-text); } +@media (max-width: 620px) { .hudtheme > span { display: none; } } + /* --------------------------------------------------------------------------- The board — Five Gates From a8995e86bb0621ffa2c4a0c6a0a78f3d2d524809 Mon Sep 17 00:00:00 2001 From: Hashem Aldhaheri <158606+aenawi@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:28:29 +0400 Subject: [PATCH 2/7] Put the whole lab on one wire, so a recording is one file (#78) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lab/README.md | 86 +++++- lab/checks/syntax.mjs | 1 + lab/control/feed.go | 461 ++++++++++++++++++++++++++++++++ lab/control/feed_test.go | 275 +++++++++++++++++++ lab/control/main.go | 27 +- lab/control/state.go | 9 +- lab/control/stream.go | 162 +---------- lab/control/ui/app.js | 116 +++++--- lab/control/ui/feed.js | 261 ++++++++++++++++++ lab/control/ui/hud/setpieces.js | 4 +- lab/control/ui/index.html | 6 + lab/design/five-gates.html | 4 +- 12 files changed, 1211 insertions(+), 201 deletions(-) create mode 100644 lab/control/feed.go create mode 100644 lab/control/feed_test.go create mode 100644 lab/control/ui/feed.js diff --git a/lab/README.md b/lab/README.md index 5e7d26d..37fed25 100644 --- a/lab/README.md +++ b/lab/README.md @@ -1272,11 +1272,13 @@ lab/ ├── actions.go one function per switch, and the five-rung probe ├── attacks.go one function per attack ├── audit.go the eleven checks - ├── stream.go server-sent events: status, tcpdump, logs, stats + ├── feed.go the one live wire: /api/stream/hud — see below + ├── stream.go the SSE plumbing, and the text the readouts read ├── panels.go the panel spec the UI renders ├── *_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 + ├── feed.js one EventSource, shared by everything that reads the lab ├── app.js a classic script, the same shape as assets/sandbox.js └── hud/ ├── three.module.js three.js r166, MIT, inside the binary @@ -1289,6 +1291,88 @@ lab/ └── warroom/ amber on near-black ``` +### The feed + +Everything live in this lab comes out of one endpoint: + +```bash +curl -N http://127.0.0.1:8099/api/stream/hud +``` + +It used to be four — `status`, `tcpdump`, `logs` and `stats` — and each +consumer opened the one it wanted and parsed it for itself. One wire is better +for two readers who are not this page. Somebody writing a card that shows drop +counts should not have to know which of four endpoints has them. And a +recording of what happened should be one file in one order, rather than four +whose clocks nobody reconciled. + +Every frame is the same envelope, and the `data:` line is complete on its own: + +```json +{"seq":41,"at":1757682401337,"type":"verdict","data":{"id":"sniff","kind":"action","result":{"ok":true,"rung":2,"…":"…"}}} +``` + +`seq` is a position on *this* connection, counted from 1, and `at` is Unix +milliseconds. Both are there for replay: the order, and how long apart. A +reconnect starts a new wire and a new `seq`, and says so with a fresh `hello`. + +Ten types, and the opening `hello` lists them so a consumer can ask rather than +assume: + +| type | carries | when | +|---|---|---| +| `hello` | the type list, what was subscribed, the server's clock | 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 | +| `stat` | `[]Stats` — CPU, memory, interface counters | every 3s | +| `verdict` | a `Result` from `/api/action`, `/api/set` or `/api/preset` | when one ran | +| `flow` | a `Result` from `/api/probe` | when one ran | +| `packet` | one line of `tcpdump` | only if `capture=` asked | +| `log` | one line of `tailscaled.log` | only if `logs=` asked | +| `note` | a source talking about itself, tagged with which source | as needed | + +The first seven cost nothing and are always on. The last three are asked for in +the URL, because a capture is a process inside a container and only the reader +knows whether anybody is looking at it: + +```bash +curl -N 'http://127.0.0.1:8099/api/stream/hud?capture=evil-box' +curl -N 'http://127.0.0.1:8099/api/stream/hud?capture=evil-box&iface=eth0&filter=icmp' +curl -N 'http://127.0.0.1:8099/api/stream/hud?logs=lab-ubuntu&logsAll=1' +``` + +There is nothing to POST and no subscription state on the server: a reader that +wants a capture reconnects with the parameter. That is what keeps a recording +honest — the URL says what was being watched. + +`verdict` and `flow` are the same `Result` the POST replied with, published as +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. + +```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 +``` + +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 +an attack that never returned to the person who ran it. The count of what was +dropped is in every `hello`, so a consumer can say there is a gap rather than +draw a straight line across it. + +On this side of the wire, `ui/feed.js` owns the one `EventSource` and everything +on the page shares it. Three calls: `on(type, fn)` to listen, `want({capture: +"evil-box"})` to ask for a source that costs something — reference counted, so +two things wanting the capture is one capture — and `status()` for what the +connection is doing. It is a classic script loaded before `app.js`, because the +readouts read the lab from their first line and the feed cannot be something the +page waits for. + ### Themes for the board The board has never carried a palette. `readTokens()` in `hud/scene.js` reads diff --git a/lab/checks/syntax.mjs b/lab/checks/syntax.mjs index 0969bdb..42123c0 100644 --- a/lab/checks/syntax.mjs +++ b/lab/checks/syntax.mjs @@ -44,6 +44,7 @@ const root = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); vendored and minified: a parse error in it means a bad copy, not a typo, and nothing in this repo would fix it by hand. */ const FILES = [ + ["lab/control/ui/feed.js", "commonjs"], ["lab/control/ui/app.js", "commonjs"], ["lab/control/ui/hud/scene.js", "module"], ["lab/control/ui/hud/setpieces.js", "module"], diff --git a/lab/control/feed.go b/lab/control/feed.go new file mode 100644 index 0000000..3446bb2 --- /dev/null +++ b/lab/control/feed.go @@ -0,0 +1,461 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Hashem Aldhaheri + +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" +) + +// --------------------------------------------------------------------------- +// The feed +// +// One wire out of this server — /api/stream/hud — carrying every live thing +// that happens in the lab as a typed event. It replaces four separate SSE +// endpoints, each of which a consumer opened for itself and parsed for itself. +// +// The reason is not tidiness. Four streams means four places that know how to +// read the lab, so a widget somebody else writes has to pick the right one and +// parse it correctly before it can draw anything, and a recording of what +// happened has to be stitched together from four files whose clocks nobody +// reconciled. One ordered wire is a thing you can hand to a stranger, and a +// thing you can write to a file and play back later with no Docker at all. +// +// So the shape below is chosen for those two readers rather than for the page +// that exists today: +// +// - Every event is the same envelope. A consumer that does not recognise a +// type can skip it by reading one field, rather than by failing to parse. +// - Every event carries its own sequence and its own timestamp. Replay needs +// both: the order events happened in, and how long apart. +// - Every event goes out as a `data:` line that is complete on its own. A +// `.jsonl` file of those lines, one per line, is the recording — there is +// no framing to reconstruct. +// +// Nothing here changes what is drawn. The board still renders an action from +// the reply to the POST that ran it; the feed carries the same Result so that +// a recording is complete, and so that a widget can watch one without being +// the thing that pressed the button. +// --------------------------------------------------------------------------- + +// Event is the envelope. Every line on the wire is one of these, and the +// `data:` payload of every SSE frame is exactly this object. +// +// Seq is a position on one wire, not a global identity: two readers connected +// at the same time each count their own frames from 1, because what a replay +// needs is the order the events reached it. At is Unix milliseconds — the +// deltas between events are the thing replay does arithmetic on, and a +// timestamp that has to be parsed before it can be subtracted is a timestamp +// that will be parsed wrongly by somebody. +type Event struct { + Seq int64 `json:"seq"` + At int64 `json:"at"` + Type string `json:"type"` + Data any `json:"data"` +} + +// FeedTypes is every type name the feed emits, and it is served in the opening +// `hello` so a consumer can find out what this build carries rather than +// guessing from a version number. +// +// The three at the end are only present when they were asked for: packet and +// log cost a process inside a container, so they run when a query parameter +// names a machine and not otherwise. +var FeedTypes = []string{ + "hello", // once, at open: what this wire carries and what was subscribed + "state", // the full State snapshot, on change + "status", // `tailscale status`, on change + "netcheck", // `tailscale netcheck`, on change + "stat", // []Stats, one sample per running container, every tick + "verdict", // a Result from /api/action, /api/set or /api/preset + "flow", // a Result from /api/probe: a measured path between two machines + "packet", // one line of tcpdump, if capture= named a machine + "log", // one line of tailscaled.log, if logs= named a machine + "note", // a source saying something about itself, not about the lab +} + +// feedResult is what a verdict or a flow carries. A bare Result does not say +// what produced it, and the set-piece a replay has to pick is chosen by id — +// so the id travels with the Result rather than being inferred from its shape. +type feedResult struct { + ID string `json:"id"` + Kind string `json:"kind"` // action | set | preset | probe + Result Result `json:"result"` +} + +// --------------------------------------------------------------------------- +// The hub: events that belong to the lab rather than to one reader +// --------------------------------------------------------------------------- + +// Feed broadcasts the events that are not any one reader's. Everything a +// reader can produce for itself — a poll of `tailscale status`, a tcpdump it +// asked for — is produced per connection; what goes through here is the things +// that happened *to the lab*, which is every Result the API returned. +// +// A subscriber that cannot keep up loses events. That is deliberate and it is +// the only safe direction: the alternative is /api/action blocking on a +// browser tab that has been backgrounded, which would make a stalled reader +// into a stalled lab. Dropped counts what was lost, so a consumer can say so +// rather than quietly showing a gap. +type Feed struct { + mu sync.Mutex + subs map[int]chan Event + nextSub int + dropped atomic.Int64 +} + +// feedBuffer is how far behind a reader may fall before it starts losing +// events. Sized for the burst an attack produces — a Result, a state change +// and a stats tick land within a few milliseconds of each other — with enough +// room left that an ordinary hiccup costs nothing. +const feedBuffer = 256 + +func NewFeed() *Feed { return &Feed{subs: map[int]chan Event{}} } + +// Publish stamps an event with the time and hands it to every subscriber. Seq +// is left at zero: it is a position on a wire, and the wire assigns it. +func (f *Feed) Publish(kind string, data any) { + ev := Event{At: time.Now().UnixMilli(), Type: kind, Data: data} + f.mu.Lock() + defer f.mu.Unlock() + for _, ch := range f.subs { + select { + case ch <- ev: + default: + f.dropped.Add(1) + } + } +} + +// Subscribe returns a channel of broadcast events and the function that stops +// it. The channel is never closed — a reader leaves by calling the returned +// function and then not reading, and closing under a concurrent Publish is a +// panic waiting for a slow afternoon. +func (f *Feed) Subscribe() (<-chan Event, func()) { + ch := make(chan Event, feedBuffer) + f.mu.Lock() + id := f.nextSub + f.nextSub++ + f.subs[id] = ch + f.mu.Unlock() + + return ch, func() { + f.mu.Lock() + delete(f.subs, id) + f.mu.Unlock() + } +} + +// Dropped is how many events have been lost to subscribers that could not keep +// up, since the server started. +func (f *Feed) Dropped() int64 { return f.dropped.Load() } + +// Subscribers is how many readers are attached. +func (f *Feed) Subscribers() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.subs) +} + +// PublishResult puts a Result on the feed under the type its kind implies: a +// probe measured a path between two machines, which is a flow; everything else +// is a verdict about the lab's configuration or an attack against it. +// +// Called from the API handlers rather than from deeper down on purpose. What +// ran is a fact about the request, not about the function that served it, and +// the id is the thing a replay needs most. +func (c *Controller) PublishResult(kind, id string, res Result) { + if c.feed == nil { + return + } + t := "verdict" + if kind == "probe" { + t = "flow" + } + c.feed.Publish(t, feedResult{ID: id, Kind: kind, Result: res}) +} + +// --------------------------------------------------------------------------- +// The wire +// --------------------------------------------------------------------------- + +// feedPollInterval is how often the polled sources are re-read. Three seconds, +// which is what the status and stats streams used before this file existed: +// `tailscale status` is an exec into a container and the lab is a laptop. +const feedPollInterval = 3 * time.Second + +// StreamHUD serves the feed. +// +// Everything that can be produced is produced by its own goroutine writing +// into one channel, and one writer takes them off that channel and puts them +// on the wire. That is what makes the sequence mean something: the order is +// decided in a single place, so two events cannot be numbered by two +// goroutines racing over the same counter. +// +// Query parameters name the on-demand sources, because a capture costs a +// process inside a container and the reader is the only one who knows whether +// anybody is looking at it: +// +// /api/stream/hud +// /api/stream/hud?capture=evil-box +// /api/stream/hud?capture=evil-box&iface=eth0&filter=udp+port+41641 +// /api/stream/hud?logs=lab-ubuntu&logsAll=1 +// +// There is no server-side subscription state and nothing to POST: a reader +// that wants a capture reconnects with the parameter. That keeps a recording +// honest — the URL says what was being watched — and it keeps this handler a +// function of its request, which is the only version of it that is testable +// without a browser. +func (c *Controller) StreamHUD(w http.ResponseWriter, r *http.Request) { + s, ok := newSSE(w) + if !ok { + return + } + + // Cancelled when the reader goes away *or* when this handler returns, so + // every producer below stops without being told twice. + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + + out := make(chan Event, feedBuffer) + global, unsubscribe := c.feed.Subscribe() + defer unsubscribe() + + capture := r.URL.Query().Get("capture") + logs := r.URL.Query().Get("logs") + + var wg sync.WaitGroup + start := func(fn func()) { + wg.Add(1) + go func() { defer wg.Done(); fn() }() + } + + start(func() { c.pollFeed(ctx, out) }) + if capture != "" { + start(func() { c.captureFeed(ctx, out, r, capture) }) + } + if logs != "" { + start(func() { c.logFeed(ctx, out, r, logs) }) + } + + // The producers hold the only senders on `out`; when they are all done + // nothing further can arrive, and the writer below can stop looking. + go func() { wg.Wait(); close(out) }() + + // First line on the wire, before anything is measured: what this build + // carries and what this connection asked for. A recording that starts with + // this can be read by something written against a different build. + var seq int64 + write := func(ev Event) { + seq++ + ev.Seq = seq + if ev.At == 0 { + ev.At = time.Now().UnixMilli() + } + s.send(ev.Type, ev) + } + + sources := []string{"state", "status", "netcheck", "stat", "verdict", "flow"} + if capture != "" { + sources = append(sources, "packet") + } + if logs != "" { + sources = append(sources, "log") + } + write(Event{Type: "hello", Data: map[string]any{ + "types": FeedTypes, + "sources": sources, + "capture": capture, + "logs": logs, + "pollMs": feedPollInterval.Milliseconds(), + "dropped": c.feed.Dropped(), + "readers": c.feed.Subscribers(), + "serverAt": time.Now().UnixMilli(), + }}) + + for { + select { + case <-ctx.Done(): + return + case ev, open := <-out: + if !open { + // Every producer finished — a capture of a machine that is not + // running ends this way, and so does a log tail whose file went + // away. Hold the connection open for the broadcast events, which + // have no producer of their own to finish. + out = nil + continue + } + write(ev) + case ev := <-global: + write(ev) + } + } +} + +// emit puts one event on the connection's channel, and gives up if the reader +// has gone. Every producer goes through here, so none of them can outlive the +// request by blocking on a send nobody will ever receive. +func emit(ctx context.Context, out chan<- Event, kind string, data any) bool { + select { + case out <- Event{At: time.Now().UnixMilli(), Type: kind, Data: data}: + return true + case <-ctx.Done(): + return false + } +} + +func emitLine(ctx context.Context, out chan<- Event, kind, text string) bool { + return emit(ctx, out, kind, map[string]string{"line": text}) +} + +// emitNote is a source saying something about itself rather than about the lab: +// the command it is about to run, or the reason it stopped. +// +// It carries which source it came from, and that field is not decoration. When +// these were four streams, a note could only have come from the stream it +// arrived on, so the capture pane printed the tcpdump banner and the log pane +// printed the tail banner and neither had to be told. On one wire both panes +// see both notes, and a capture pane headed `tail -f /var/log/tailscaled.log` +// is a pane lying about where its lines came from. +func emitNote(ctx context.Context, out chan<- Event, source, text string) bool { + return emit(ctx, out, "note", map[string]string{"source": source, "line": text}) +} + +// --------------------------------------------------------------------------- +// The polled sources: status, netcheck, state, stats +// --------------------------------------------------------------------------- + +// pollFeed reads the four things that have to be asked for rather than pushed. +// +// status, netcheck and state are diffed and only sent when they changed, which +// is what the status stream did: three seconds of an unchanged `tailscale +// status` is not news, and a recording full of it is a recording nobody will +// read. stat is sent every tick regardless, because a counter that did not +// move is the measurement, not the absence of one. +func (c *Controller) pollFeed(ctx context.Context, out chan<- Event) { + var lastStatus, lastNet, lastState string + + tick := time.NewTicker(feedPollInterval) + defer tick.Stop() + + for { + if text := c.statusText(ctx); text != lastStatus { + lastStatus = text + if !emit(ctx, out, "status", map[string]string{"text": text}) { + return + } + } + if text := c.netcheckText(ctx); text != lastNet { + lastNet = text + if !emit(ctx, out, "netcheck", map[string]string{"text": text}) { + return + } + } + + c.Observe(ctx) + if b, _ := json.Marshal(c.Snapshot()); string(b) != lastState { + lastState = string(b) + if !emit(ctx, out, "state", json.RawMessage(b)) { + return + } + } + + var stats []Stats + for _, m := range Catalog { + if !c.lab.Running(ctx, m.ID) { + continue + } + if st, err := c.lab.Stats(ctx, m.ID); err == nil { + stats = append(stats, st) + } + } + if !emit(ctx, out, "stat", stats) { + return + } + + select { + case <-ctx.Done(): + return + case <-tick.C: + } + } +} + +// --------------------------------------------------------------------------- +// The on-demand sources: tcpdump, and the tailscaled log +// --------------------------------------------------------------------------- + +// captureFeed is the live capture, straight off the wire. One packet event per +// real frame — the motion a set-piece draws from this is measured, not +// invented, and that is the property the note above the tray is claiming. +func (c *Controller) captureFeed(ctx context.Context, out chan<- Event, r *http.Request, machine string) { + if !c.lab.Running(ctx, machine) { + emitNote(ctx, out, "capture", machine+" is not running, so there is nothing to listen to.") + return + } + + iface := q(r, "iface", "") + if iface == "" { + if machine == "evil-box" { + iface = c.ifaceOn(ctx, "evil-box", c.lab.IPOn(ctx, "evil-box", "lan-ubuntu")) + } else { + iface = "eth0" + } + } + filter := q(r, "filter", "udp port 41641") + + emitNote(ctx, out, "capture", fmt.Sprintf("%s # tcpdump -l -n -X -i %s '%s'", machine, iface, filter)) + argv := []string{"tcpdump", "-l", "-n", "-X", "-i", iface, filter} + + err := c.lab.ExecStream(ctx, machine, argv, func(line string) { + emitLine(ctx, out, "packet", line) + }) + if err != nil && ctx.Err() == nil { + emitNote(ctx, out, "capture", "capture ended: "+err.Error()) + } +} + +// pathWords are the tailscaled log lines about how it chose a path, which is +// the only part of that log this lab is about. Everything else is noise at a +// rate no reader can follow; logsAll=1 turns the filter off for someone who +// wants the rest. +var pathWords = []string{ + "magicsock", "derp", "endpoint", "portmap", "netcheck", + "disco", "direct", "relay", "home is", "control:", +} + +func (c *Controller) logFeed(ctx context.Context, out chan<- Event, r *http.Request, machine string) { + if !c.lab.Running(ctx, machine) { + emitNote(ctx, out, "logs", machine+" is not running.") + return + } + all := q(r, "logsAll", "") == "1" + emitNote(ctx, out, "logs", machine+" # tail -f /var/log/tailscaled.log") + + err := c.lab.ExecStream(ctx, machine, []string{"tail", "-n", "40", "-f", "/var/log/tailscaled.log"}, + func(line string) { + if all || matchesAny(strings.ToLower(line), pathWords) { + emitLine(ctx, out, "log", line) + } + }) + if err != nil && ctx.Err() == nil { + emitNote(ctx, out, "logs", "log stream ended: "+err.Error()) + } +} + +func matchesAny(s string, words []string) bool { + for _, w := range words { + if strings.Contains(s, w) { + return true + } + } + return false +} diff --git a/lab/control/feed_test.go b/lab/control/feed_test.go new file mode 100644 index 0000000..4e39541 --- /dev/null +++ b/lab/control/feed_test.go @@ -0,0 +1,275 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Hashem Aldhaheri + +package main + +import ( + "context" + "encoding/json" + "slices" + "strings" + "testing" + "time" +) + +// --------------------------------------------------------------------------- +// The feed +// +// Nothing here needs a container, and that is most of the point: the hub, the +// envelope and the promise that a slow reader cannot stall the lab are all +// decidable without Docker, so they are decided here and CI gets to have an +// opinion about them. +// +// What is *not* here, and cannot be: whether `tailscale status` comes back, +// whether tcpdump finds an interface, whether the wire reconnects. Those are +// measured against the running lab and written into the pull request, because +// a test that claims them would be claiming something it did not observe. +// --------------------------------------------------------------------------- + +func TestFeedFanOut(t *testing.T) { + f := NewFeed() + + a, stopA := f.Subscribe() + b, stopB := f.Subscribe() + defer stopA() + defer stopB() + + if got := f.Subscribers(); got != 2 { + t.Fatalf("Subscribers() = %d, want 2", got) + } + + f.Publish("verdict", map[string]string{"id": "nat-slam"}) + + for i, ch := range []<-chan Event{a, b} { + select { + case ev := <-ch: + if ev.Type != "verdict" { + t.Errorf("subscriber %d got type %q, want verdict", i, ev.Type) + } + if ev.At == 0 { + t.Errorf("subscriber %d got an event with no timestamp", i) + } + // Seq is the wire's to assign, not the hub's: two readers are two + // wires, and an event numbered by the hub would be numbered for + // whichever of them happened to be first. + if ev.Seq != 0 { + t.Errorf("subscriber %d got Seq %d from the hub, want 0", i, ev.Seq) + } + case <-time.After(time.Second): + t.Fatalf("subscriber %d never received the event", i) + } + } +} + +func TestFeedUnsubscribeStopsDelivery(t *testing.T) { + f := NewFeed() + ch, stop := f.Subscribe() + + stop() + if got := f.Subscribers(); got != 0 { + t.Fatalf("Subscribers() = %d after stop, want 0", got) + } + + f.Publish("state", nil) + select { + case ev := <-ch: + t.Fatalf("a stopped subscriber still received %q", ev.Type) + case <-time.After(50 * time.Millisecond): + } + + // Calling stop twice is what a deferred unsubscribe does when the handler + // also returned early, and it must not panic or take a second reader with + // it. + stop() +} + +// A reader that stops reading must lose events rather than hold the lab up. +// The failure this is about is not hypothetical in shape: Publish is called +// from inside /api/action, so a send that blocks is an attack that never +// returns to the person who ran it. +func TestFeedDropsRatherThanBlocks(t *testing.T) { + f := NewFeed() + _, stop := f.Subscribe() + defer stop() + + // Nothing reads the channel. Fill it, then overrun it by a clear margin. + over := 32 + done := make(chan struct{}) + go func() { + for range feedBuffer + over { + f.Publish("stat", nil) + } + close(done) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Publish blocked on a subscriber that was not reading") + } + + if got := f.Dropped(); got != int64(over) { + t.Errorf("Dropped() = %d, want %d", got, over) + } +} + +// The envelope is the contract with everything that is not this page: a widget +// somebody else writes, and a `.jsonl` recording played back with no server at +// all. Renaming a field here breaks both of them silently, so the names are +// pinned. +func TestEventEnvelopeJSON(t *testing.T) { + b, err := json.Marshal(Event{Seq: 7, At: 1757660000000, Type: "flow", + Data: map[string]string{"id": "probe"}}) + if err != nil { + t.Fatalf("marshalling an Event: %v", err) + } + + want := `{"seq":7,"at":1757660000000,"type":"flow","data":{"id":"probe"}}` + if string(b) != want { + t.Errorf("Event marshalled as\n %s\nwant\n %s", b, want) + } +} + +// A Result on the feed without the id of what produced it is a Result a replay +// cannot pick a set-piece for — which was the whole reason for putting it on +// the feed. So: what ran travels with what happened. +func TestPublishResultCarriesWhatRan(t *testing.T) { + for _, tc := range []struct { + kind, id string + wantType string + wantKind string + wantIDStr string + }{ + {"action", "nat-slam", "verdict", "action", "nat-slam"}, + {"set", "vps.allowPublic22", "verdict", "set", "vps.allowPublic22"}, + {"preset", "weak", "verdict", "preset", "weak"}, + {"probe", "probe", "flow", "probe", "probe"}, + } { + t.Run(tc.kind+"/"+tc.id, func(t *testing.T) { + c := &Controller{feed: NewFeed()} + ch, stop := c.feed.Subscribe() + defer stop() + + c.PublishResult(tc.kind, tc.id, Result{OK: true, Rung: 4, Rule: "a rule"}) + + select { + case ev := <-ch: + if ev.Type != tc.wantType { + t.Errorf("type = %q, want %q", ev.Type, tc.wantType) + } + fr, ok := ev.Data.(feedResult) + if !ok { + t.Fatalf("Data is %T, want feedResult", ev.Data) + } + if fr.ID != tc.wantIDStr || fr.Kind != tc.wantKind { + t.Errorf("carried {id:%q kind:%q}, want {id:%q kind:%q}", + fr.ID, fr.Kind, tc.wantIDStr, tc.wantKind) + } + // The Result goes through whole. A feed that summarised it would + // be a second engine, and the rung is the thing this repository + // refuses to reduce to a pass/fail. + if fr.Result.Rung != 4 || fr.Result.Rule != "a rule" { + t.Errorf("Result arrived as %+v, want rung 4 and its rule", fr.Result) + } + case <-time.After(time.Second): + t.Fatal("nothing was published") + } + }) + } +} + +// A Controller built without a feed is what a test does, and it must not take +// the process down for it. +func TestPublishResultWithNoFeed(t *testing.T) { + c := &Controller{} + c.PublishResult("action", "nat-slam", Result{}) // must not panic +} + +// FeedTypes is served in the opening hello, and a consumer is told to read it +// rather than guess. A type the wire emits but the list omits makes that +// promise false. +func TestFeedTypesCoversWhatIsEmitted(t *testing.T) { + for _, want := range []string{ + "hello", "state", "status", "netcheck", "stat", + "verdict", "flow", "packet", "log", "note", + } { + if !slices.Contains(FeedTypes, want) { + t.Errorf("FeedTypes omits %q, which the wire emits", want) + } + } + if len(FeedTypes) != 10 { + t.Errorf("FeedTypes has %d entries; if a type was added, add it to this "+ + "test and to the feed's documentation in lab/README.md", len(FeedTypes)) + } +} + +// Every producer sends through emit, which is what stops one outliving the +// request it belongs to. A cancelled context has to end the send rather than +// wait for a reader that has gone. +func TestEmitGivesUpWhenTheReaderHasGone(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + out := make(chan Event) // unbuffered: the send cannot complete on its own + + cancel() + if emit(ctx, out, "packet", nil) { + t.Error("emit reported a send on a cancelled context") + } + if emitLine(ctx, out, "note", "anything") { + t.Error("emitLine reported a send on a cancelled context") + } +} + +func TestEmitLineShape(t *testing.T) { + ctx := context.Background() + out := make(chan Event, 1) + + if !emitLine(ctx, out, "packet", "10.0.13.5.41641 > 10.0.66.9.41641") { + t.Fatal("emitLine reported no send on a live context") + } + ev := <-out + + // The text sources all carry {"line": "..."} and the page reads that one + // key for packet, log and note alike. Three shapes for three text streams + // is three parsers, which is what this branch is removing. + m, ok := ev.Data.(map[string]string) + if !ok { + t.Fatalf("Data is %T, want map[string]string", ev.Data) + } + if m["line"] != "10.0.13.5.41641 > 10.0.66.9.41641" { + t.Errorf("line = %q, want the packet", m["line"]) + } + if ev.At == 0 { + t.Error("emitLine produced an event with no timestamp") + } +} + +// On four streams a note could only have come from the stream it arrived on. On +// one wire it has to say, or the capture pane prints the log tail's banner and +// claims the lines under it came from tcpdump. +func TestNotesSayWhichSourceTheyCameFrom(t *testing.T) { + ctx := context.Background() + out := make(chan Event, 2) + + emitNote(ctx, out, "capture", "evil-box # tcpdump -l -n -X -i eth1 'udp port 41641'") + emitNote(ctx, out, "logs", "lab-ubuntu # tail -f /var/log/tailscaled.log") + + for _, want := range []struct{ source, contains string }{ + {"capture", "tcpdump"}, + {"logs", "tail -f"}, + } { + ev := <-out + if ev.Type != "note" { + t.Fatalf("type = %q, want note", ev.Type) + } + m, ok := ev.Data.(map[string]string) + if !ok { + t.Fatalf("Data is %T, want map[string]string", ev.Data) + } + if m["source"] != want.source { + t.Errorf("source = %q, want %q", m["source"], want.source) + } + if !strings.Contains(m["line"], want.contains) { + t.Errorf("line %q does not mention %q", m["line"], want.contains) + } + } +} diff --git a/lab/control/main.go b/lab/control/main.go index 0bf8d2e..6414953 100644 --- a/lab/control/main.go +++ b/lab/control/main.go @@ -568,10 +568,22 @@ func routes(c *Controller) http.Handler { writeJSON(w, c.Snapshot()) }) + // Every one of the four below does the same three things in the same order, + // and the order is the point. The reply is what the board draws from, so it + // is written last and nothing can delay it; the journal and the feed are + // both records of what happened, and a record written after the reply is a + // record of something the reader has already been told. + // + // The feed carries the Result as well as the reply carrying it. That is not + // a duplicate source — it is the same value marshalled twice — and it is + // what lets something that did not press the button see what the button + // did. See the top of feed.go. mux.HandleFunc("/api/set", func(w http.ResponseWriter, r *http.Request) { m := body(r) - res := c.Set(r.Context(), str(m, "path", ""), m["value"]) + path := str(m, "path", "") + res := c.Set(r.Context(), path, m["value"]) record(res.Cmds) + c.PublishResult("set", path, res) writeJSON(w, res) }) @@ -580,6 +592,7 @@ func routes(c *Controller) http.Handler { res := c.Probe(r.Context(), str(m, "from", "lab-ubuntu"), str(m, "to", "lab-vps"), str(m, "port", "22")) record(res.Cmds) + c.PublishResult("probe", "probe", res) writeJSON(w, res) }) @@ -594,13 +607,16 @@ func routes(c *Controller) http.Handler { res = c.RunAttack(r.Context(), id) } record(res.Cmds) + c.PublishResult("action", id, res) writeJSON(w, res) }) mux.HandleFunc("/api/preset", func(w http.ResponseWriter, r *http.Request) { m := body(r) - res := c.ApplyPreset(r.Context(), str(m, "id", "")) + id := str(m, "id", "") + res := c.ApplyPreset(r.Context(), id) record(res.Cmds) + c.PublishResult("preset", id, res) writeJSON(w, res) }) @@ -618,10 +634,9 @@ func routes(c *Controller) http.Handler { writeText(w, scriptText()) }) - mux.HandleFunc("/api/stream/status", c.StreamStatus) - mux.HandleFunc("/api/stream/tcpdump", c.StreamTcpdump) - mux.HandleFunc("/api/stream/logs", c.StreamLogs) - mux.HandleFunc("/api/stream/stats", c.StreamStats) + // One wire, replacing /api/stream/{status,tcpdump,logs,stats}. Why, and + // what is on it, is at the top of feed.go. + mux.HandleFunc("/api/stream/hud", c.StreamHUD) return mux } diff --git a/lab/control/state.go b/lab/control/state.go index 2db0d01..a2da2c6 100644 --- a/lab/control/state.go +++ b/lab/control/state.go @@ -276,6 +276,13 @@ type Controller struct { mu sync.Mutex state *State + // The one wire out. Every Result the API returns is published here as well + // as being replied with, so a reader watching the feed sees what happened + // without having been the thing that asked for it. Never nil in the server; + // a test that builds a Controller directly may leave it so, and + // PublishResult is a no-op when it is. + feed *Feed + stateDir string // Held back until "tailnet lock" is turned off. An attacker with no key // gets no session, which is the property the check is really about. @@ -285,7 +292,7 @@ type Controller struct { func NewController(lab *Lab, stateDir string) *Controller { return &Controller{ - lab: lab, state: defaultState(), stateDir: stateDir, + lab: lab, state: defaultState(), stateDir: stateDir, feed: NewFeed(), log: func(f string, a ...any) { fmt.Printf("[control] "+f+"\n", a...) }, } } diff --git a/lab/control/stream.go b/lab/control/stream.go index 4295336..7e58e97 100644 --- a/lab/control/stream.go +++ b/lab/control/stream.go @@ -15,9 +15,14 @@ import ( // --------------------------------------------------------------------------- // Server-sent events // -// Four streams, all one-way, all over plain SSE. No websockets and no client -// library: the readout panes are the only thing that needs live data, and they -// only ever need it pushed at them. +// One stream, one-way, over plain SSE. No websockets and no client library: +// nothing here needs to be asked a question, it only ever needs to be pushed +// at, and SSE reconnects on its own. +// +// This file is the plumbing and the two things the wire is made of that are +// also wanted elsewhere — `tailscale status` and `netcheck`, which the feed +// polls, and the rules pane, which is a plain GET. The wire itself, and every +// source on it, is in feed.go. // --------------------------------------------------------------------------- type sse struct { @@ -53,45 +58,6 @@ func (s *sse) send(event string, v any) { s.fl.Flush() } -func (s *sse) line(event, text string) { s.send(event, map[string]string{"line": text}) } - -// --------------------------------------------------------------------------- -// status — tailscale status and netcheck, polled, diffed, pushed on change -// --------------------------------------------------------------------------- - -func (c *Controller) StreamStatus(w http.ResponseWriter, r *http.Request) { - s, ok := newSSE(w) - if !ok { - return - } - ctx := r.Context() - var lastStatus, lastNet, lastState string - - tick := time.NewTicker(3 * time.Second) - defer tick.Stop() - - for { - if text := c.statusText(ctx); text != lastStatus { - lastStatus = text - s.send("status", map[string]string{"text": text}) - } - if text := c.netcheckText(ctx); text != lastNet { - lastNet = text - s.send("netcheck", map[string]string{"text": text}) - } - c.Observe(ctx) - if b, _ := json.Marshal(c.Snapshot()); string(b) != lastState { - lastState = string(b) - s.send("state", json.RawMessage(b)) - } - select { - case <-ctx.Done(): - return - case <-tick.C: - } - } -} - // statusText is `tailscale status` from the laptop's point of view — the same // vantage point the sandbox page prints it from. func (c *Controller) statusText(ctx context.Context) string { @@ -122,118 +88,6 @@ func (c *Controller) netcheckText(ctx context.Context) string { return self + " $ tailscale netcheck\n" + strings.TrimRight(r.Out(), "\n") } -// --------------------------------------------------------------------------- -// tcpdump — the live capture, straight off the wire -// --------------------------------------------------------------------------- - -func (c *Controller) StreamTcpdump(w http.ResponseWriter, r *http.Request) { - s, ok := newSSE(w) - if !ok { - return - } - ctx := r.Context() - - machine := q(r, "machine", "evil-box") - if !c.lab.Running(ctx, machine) { - s.line("note", machine+" is not running, so there is nothing to listen to.") - return - } - iface := q(r, "iface", "") - if iface == "" { - if machine == "evil-box" { - iface = c.ifaceOn(ctx, "evil-box", c.lab.IPOn(ctx, "evil-box", "lan-ubuntu")) - } else { - iface = "eth0" - } - } - filter := q(r, "filter", "udp port 41641") - - s.line("note", fmt.Sprintf("%s # tcpdump -l -n -X -i %s '%s'", machine, iface, filter)) - argv := []string{"tcpdump", "-l", "-n", "-X", "-i", iface, filter} - - err := c.lab.ExecStream(ctx, machine, argv, func(line string) { - s.line("packet", line) - }) - if err != nil && ctx.Err() == nil { - s.line("note", "capture ended: "+err.Error()) - } -} - -// --------------------------------------------------------------------------- -// logs — tailscaled, filtered to the lines about how it chose a path -// --------------------------------------------------------------------------- - -var pathWords = []string{ - "magicsock", "derp", "endpoint", "portmap", "netcheck", - "disco", "direct", "relay", "home is", "control:", -} - -func (c *Controller) StreamLogs(w http.ResponseWriter, r *http.Request) { - s, ok := newSSE(w) - if !ok { - return - } - ctx := r.Context() - machine := q(r, "machine", "lab-ubuntu") - if !c.lab.Running(ctx, machine) { - s.line("note", machine+" is not running.") - return - } - all := q(r, "all", "") == "1" - s.line("note", machine+" # tail -f /var/log/tailscaled.log") - - err := c.lab.ExecStream(ctx, machine, []string{"tail", "-n", "40", "-f", "/var/log/tailscaled.log"}, - func(line string) { - if all || matchesAny(strings.ToLower(line), pathWords) { - s.line("log", line) - } - }) - if err != nil && ctx.Err() == nil { - s.line("note", "log stream ended: "+err.Error()) - } -} - -func matchesAny(s string, words []string) bool { - for _, w := range words { - if strings.Contains(s, w) { - return true - } - } - return false -} - -// --------------------------------------------------------------------------- -// stats — CPU, memory and interface counters, per container -// --------------------------------------------------------------------------- - -func (c *Controller) StreamStats(w http.ResponseWriter, r *http.Request) { - s, ok := newSSE(w) - if !ok { - return - } - ctx := r.Context() - tick := time.NewTicker(3 * time.Second) - defer tick.Stop() - - for { - var out []Stats - for _, m := range Catalog { - if !c.lab.Running(ctx, m.ID) { - continue - } - if st, err := c.lab.Stats(ctx, m.ID); err == nil { - out = append(out, st) - } - } - s.send("stats", out) - select { - case <-ctx.Done(): - return - case <-tick.C: - } - } -} - func q(r *http.Request, key, def string) string { if v := r.URL.Query().Get(key); v != "" { return v diff --git a/lab/control/ui/app.js b/lab/control/ui/app.js index b17a4cb..4189469 100644 --- a/lab/control/ui/app.js +++ b/lab/control/ui/app.js @@ -102,25 +102,34 @@ }; } - /* One EventSource at a time for the board, closed the moment the next - action starts. A set-piece that keeps a stream open after its shot has - gone is a set-piece drawing frames that belong to something else. */ + /* One watch at a time for the board, dropped the moment the next action + starts. A set-piece that keeps reading after its shot has gone is a + set-piece drawing frames that belong to something else. + + A set-piece used to be handed a URL and left to open it. Now it says what + it wants to watch and the feed works out whether anything has to be started + for it: two things wanting the capture is one capture, and the shot ending + while the packets pane is still on takes nothing away from the pane. That + bookkeeping does not belong in a file about drawing. */ function hudCtx() { return { - openStream: function (url, event, onLine) { + watch: function (sources, type, onEvent) { stopHudStream(); - try { - var es = new EventSource(url); - hudStream = es; - es.addEventListener(event, function (ev) { onLine(ev); }); - es.onerror = function () { /* EventSource reconnects on its own */ }; - return function () { if (hudStream === es) stopHudStream(); }; - } catch (e) { return function () {}; } + if (!feed) return function () {}; + var offEvent = feed.on(type, onEvent); + var releaseWant = feed.want(sources); + var mine = { off: offEvent, release: releaseWant }; + hudStream = mine; + return function () { if (hudStream === mine) stopHudStream(); }; } }; } function stopHudStream() { - if (hudStream) { hudStream.close(); hudStream = null; } + if (!hudStream) return; + var gone = hudStream; + hudStream = null; + gone.off(); + gone.release(); } function boardOn() { return !!board && view === "board"; } @@ -851,9 +860,16 @@ verdict(rep.tone, rep.verdict); } - /* ---- live streams ------------------------------------------------ */ - var streams = {}; - function stopStream(k) { if (streams[k]) { streams[k].close(); delete streams[k]; } } + /* ---- the live wire ------------------------------------------------ + One /api/stream/hud, owned by feed.js, and every readout below listens to + it. This used to be four EventSources opened in four places — status here, + tcpdump in the capture toggle, the tailscaled log in its own, and a fourth + inside the board's set-piece context — which is four connections to leak + and four copies of "parse the data and hope it is the shape I expect". + + The panes are the same panes. What changed is that a widget nobody has + written yet can read the same lab without opening anything. */ + var feed = window.LabFeed || null; function tailInto(el, line) { el.textContent += (el.textContent ? "\n" : "") + line; var lines = el.textContent.split("\n"); @@ -861,18 +877,49 @@ el.scrollTop = el.scrollHeight; } - function openStatusStream() { - var es = new EventSource("/api/stream/status"); - streams.status = es; + /* The two text panes, each fed off the one wire. + + A pane is attached once and then left attached: a listener is a callback, + and the expensive half — the process inside the container — is started and + stopped by asking the feed for the source, not by adding and removing + handlers. What the switch controls is the cost, not the wiring. + + Two filters, both load-bearing: + + - `on()` checks the switch, because a line can still be in flight when + it goes off and a pane that keeps growing while hidden is a pane that + will be wrong when it is shown again. + - `d.source` checks which source the note came from. On four streams a + note 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 + this line gets you. */ + var attached = {}; + function attachPane(key, el, lineType, source, on) { + if (attached[key]) return; + attached[key] = true; + feed.on(lineType, function (d) { if (on() && d) tailInto(el, d.line); }); + feed.on("note", function (d) { if (on() && d && d.source === source) tailInto(el, "# " + d.line); }); + } + + /* The feed hands back a release function per request; kept per pane so a + second flip cannot lose the first one's. */ + var release = {}; + function releaseSource(k) { if (release[k]) { release[k](); delete release[k]; } } + + function openFeed() { + if (!feed) { + verdict("bad", "feed.js did not load, so the readouts will not update on their own."); + return; + } var status = "", netcheck = ""; function paint() { var m = $("#status-out").getAttribute("data-machines") || ""; $("#status-out").textContent = status + "\n\n" + netcheck + (m ? "\n\n# " + m : ""); } - es.addEventListener("status", function (e) { status = JSON.parse(e.data).text; paint(); }); - es.addEventListener("netcheck", function (e) { netcheck = JSON.parse(e.data).text; paint(); }); - es.addEventListener("state", function (e) { - state = JSON.parse(e.data); + feed.on("status", function (d) { status = d.text; paint(); }); + feed.on("netcheck", function (d) { netcheck = d.text; paint(); }); + feed.on("state", function (d) { + state = d; paintPanels(); /* This is the only thing that notices a change nobody clicked — a container stopping, `make weak` from another terminal, a session @@ -881,7 +928,6 @@ waits rather than wiping it. */ if (board && !board.holding) board.setState(state); }); - es.onerror = function () { /* EventSource reconnects on its own */ }; } /* ---- wiring ------------------------------------------------------ */ @@ -935,26 +981,28 @@ parseInt(n.value, 10)); return; } + /* The two toggles that cost a process inside a container. Asking the feed + for the source is what makes the server start it; the listeners stay + attached either way, because a listener is a callback and the pane is + hidden when the toggle is off. */ if (n.id === "cap-on") { var out = $("#cap-out"); out.hidden = !n.checked; - if (!n.checked) { stopStream("cap"); return; } + if (!n.checked) { releaseSource("cap"); return; } out.textContent = ""; - var es = new EventSource("/api/stream/tcpdump?machine=evil-box"); - streams.cap = es; - es.addEventListener("packet", function (ev) { tailInto(out, JSON.parse(ev.data).line); }); - es.addEventListener("note", function (ev) { tailInto(out, "# " + JSON.parse(ev.data).line); }); + if (!feed) { tailInto(out, "# feed.js did not load, so there is nothing to listen with."); return; } + attachPane("cap", out, "packet", "capture", function () { return $("#cap-on").checked; }); + release.cap = feed.want({ capture: "evil-box" }); return; } if (n.id === "logs-on") { var lo = $("#logs-out"); lo.hidden = !n.checked; - if (!n.checked) { stopStream("logs"); return; } + if (!n.checked) { releaseSource("logs"); return; } lo.textContent = ""; - var ls = new EventSource("/api/stream/logs?machine=lab-ubuntu"); - streams.logs = ls; - ls.addEventListener("log", function (ev) { tailInto(lo, JSON.parse(ev.data).line); }); - ls.addEventListener("note", function (ev) { tailInto(lo, "# " + JSON.parse(ev.data).line); }); + if (!feed) { tailInto(lo, "# feed.js did not load, so there is nothing to listen with."); return; } + attachPane("logs", lo, "log", "logs", function () { return $("#logs-on").checked; }); + release.logs = feed.want({ logs: "lab-ubuntu" }); } }); @@ -1126,7 +1174,7 @@ return refresh(); }).then(function () { renderPanels(); - openStatusStream(); + openFeed(); startBoard(); /* First visit opens the documentation rather than a log of a lab that has not done anything yet. After that it is wherever you last were. */ diff --git a/lab/control/ui/feed.js b/lab/control/ui/feed.js new file mode 100644 index 0000000..9d97465 --- /dev/null +++ b/lab/control/ui/feed.js @@ -0,0 +1,261 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + Copyright (C) 2026 Hashem Aldhaheri */ + +/* ============================================================ + The feed, on this side of the wire. + + One EventSource on /api/stream/hud, and everything on the page that wants + live data asks this rather than opening its own. Before this file there + were four endpoints and four call sites, each with its own `new + EventSource`, its own `JSON.parse(ev.data)` and its own idea of when to + close — which is four chances to leak a connection and four places to fix + a bug in how the lab is read. + + The whole surface is three calls: + + on(type, fn) -> off() listen to one event type + want(sources) -> release() ask for a source that costs something + status() what the connection is doing + + `on` is free: the cheap sources — state, status, netcheck, stat, verdict, + flow — are always on the wire, so listening is a callback and nothing more. + + `want` is for the two that are not free. A tcpdump is a process inside a + container and a log tail is another, so the server only runs them when the + URL asked, and the URL is how it is asked. Requests are reference + counted and the most recent one wins: two things wanting a capture is one + capture, and the last of them to ask picks the machine. Releasing the last + request drops the source and leaves the rest of the wire alone. + + A change of sources means reconnecting, because there is no subscription + state on the server to change — that is deliberate, and it is what makes a + recording of the wire say what was being watched. Reconnects are coalesced + to the end of the turn, so flipping two switches at once is one + connection rather than two. + + Everything here survives having no server: EventSource retries on its own + and a page with a dead feed is a page whose readouts are stale, which is + what it was. Nothing here throws at a caller. + + A classic script, not a module, and deliberately: app.js is classic and + runs before the board's module lands, so the feed cannot be something the + page has to wait for. It hands itself over on window.LabFeed. + ============================================================ */ +(function () { + "use strict"; + + /* The names this build knows to listen for before the server has said. + The server's own list arrives in `hello` and is used from the next + connection onwards, so a server that grows a type is picked up without + this list being edited — but the first connection has to guess, and + guessing the list it shipped with is the only honest guess available. */ + var KNOWN_TYPES = [ + "hello", "state", "status", "netcheck", "stat", + "verdict", "flow", "packet", "log", "note" + ]; + + function createFeed(path) { + var url = path || "/api/stream/hud"; + + var es = null; /* the one connection, or null */ + var listeners = {}; /* type -> [fn] */ + var wants = {}; /* token -> {capture, iface, filter, logs, logsAll} */ + var nextToken = 1; + var pending = false; /* a reconnect is already queued for this turn */ + + var last = { /* what the connection last told us */ + open: false, + href: "", + hello: null, + seq: 0, + opened: 0, + errors: 0 + }; + + /* ---- listeners -------------------------------------------------- + Kept per type rather than as one list with a filter, because the + always-on sources tick every three seconds and a page with a dozen + widgets should not run a dozen predicates to decide that a `stat` is + not a `verdict`. */ + function on(type, fn) { + if (typeof fn !== "function") return function () {}; + (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. A widget with a + bug is a widget with a bug; it is not a reason for the board to stop + being told what the lab is doing. */ + 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("feed listener for " + type + ": " + e); + } + } + } + + /* ---- what the URL should be ------------------------------------- + The union of what everybody wants, with the most recent request + winning where two disagree. Tokens count up, so the highest token that + named a thing is the newest opinion about it. */ + function query() { + var tokens = Object.keys(wants).map(Number).sort(function (a, b) { return a - b; }); + var w = {}; + tokens.forEach(function (t) { + var s = wants[t]; + if (s.capture) { w.capture = s.capture; w.iface = s.iface || ""; w.filter = s.filter || ""; } + if (s.logs) { w.logs = s.logs; w.logsAll = s.logsAll ? "1" : ""; } + }); + + var parts = []; + ["capture", "iface", "filter", "logs", "logsAll"].forEach(function (k) { + if (w[k]) parts.push(encodeURIComponent(k) + "=" + encodeURIComponent(w[k])); + }); + return parts.length ? url + "?" + parts.join("&") : url; + } + + /* ---- the connection ---------------------------------------------- */ + function connect() { + pending = false; + var href = query(); + + /* Already on the right URL and not in an error state: nothing to do. + This is what makes a release-then-want pair — which is what a + set-piece ending and the next one starting looks like — cost no + connection at all. */ + if (es && last.href === href && es.readyState !== 2) return; + + drop(); + last.href = href; + + try { + es = new EventSource(href); + } catch (e) { + es = null; + last.open = false; + return; + } + + var mine = es; + es.onopen = function () { + if (es !== mine) return; + last.open = true; + last.opened++; + }; + es.onerror = function () { + if (es !== mine) return; + last.open = false; + last.errors++; + /* EventSource reconnects on its own, and on its own schedule. Doing + it by hand here is how you get two connections. */ + }; + + /* One handler per type rather than one `onmessage`: the server names + every frame, so `addEventListener` is doing the dispatch the browser + is already built to do, and an unknown type costs nothing. */ + var types = (last.hello && last.hello.types) || KNOWN_TYPES; + types.forEach(function (type) { listen(mine, type); }); + } + + function listen(source, type) { + source.addEventListener(type, function (raw) { + if (es !== source) return; /* a frame from a connection we replaced */ + var ev; + try { + ev = JSON.parse(raw.data); + } catch (e) { + return; /* a half-written frame; the next one is whole */ + } + if (!ev || typeof ev !== "object") return; + + if (typeof ev.seq === "number") last.seq = ev.seq; + if (ev.type === "hello") last.hello = ev.data || null; + + fanOut(ev.type || type, ev); + fanOut("*", ev); + }); + } + + function drop() { + if (!es) return; + var gone = es; + es = null; + last.open = false; + try { gone.close(); } catch (e) {} + } + + /* Coalesced to the end of the turn. Two switches flipped in one click + handler, or a set-piece releasing a capture as the next one asks for + it, should be one connection rather than a pair of them opened and + thrown away. */ + function reconnectSoon() { + if (pending) return; + pending = true; + setTimeout(connect, 0); + } + + /* ---- the sources that cost something ---------------------------- */ + function want(sources) { + if (!sources || (!sources.capture && !sources.logs)) return function () {}; + var token = nextToken++; + wants[token] = sources; + reconnectSoon(); + + var released = false; + return function () { + if (released) return; + released = true; + delete wants[token]; + reconnectSoon(); + }; + } + + function status() { + return { + open: last.open, + href: last.href, + seq: last.seq, + opens: last.opened, + errors: last.errors, + /* The server's count of what it threw away because a reader could not + keep up, as of the last hello. A number that is not zero means a + gap somewhere, and a consumer that cares should say so rather than + draw a straight line across it. */ + dropped: (last.hello && last.hello.dropped) || 0, + types: (last.hello && last.hello.types) || null, + sources: (last.hello && last.hello.sources) || null + }; + } + + connect(); + + return { + on: on, + off: off, + want: want, + status: status, + /* For a page being torn down, and for a test. A closed feed reopens on + the next want(). */ + close: drop, + reconnect: reconnectSoon + }; + } + + /* One per page. The feed is the page's data layer rather than the board's, + so it lives beside app.js and not under hud/: the flat drawing, the + readout panes and the board all read the same wire, and two of those + three exist when there is no WebGL. */ + window.LabFeed = createFeed(); + window.LabFeed.create = createFeed; +})(); diff --git a/lab/control/ui/hud/setpieces.js b/lab/control/ui/hud/setpieces.js index 93b104c..effbd7b 100644 --- a/lab/control/ui/hud/setpieces.js +++ b/lab/control/ui/hud/setpieces.js @@ -367,10 +367,10 @@ function sniff(board, res, ctx) { than invented, which is the whole ethic of this directory. The stream is live traffic now, not the frames the capture already holds, and it is labelled as such. */ - if (ctx && ctx.openStream && r.frames > 0 && !board.reduced) { + if (ctx && ctx.watch && r.frames > 0 && !board.reduced) { let n = 0; const live = []; - const stop = ctx.openStream("/api/stream/tcpdump?machine=evil-box", "packet", () => { + const stop = ctx.watch({ capture: "evil-box" }, "packet", () => { if (n++ > 60) return; const d = put(board, board.mk.sphere(0.06, C.danger, 0.85), ux + 3.4 + (Math.random() - 0.5) * 0.6, Y_MACH - 0.5, uz); diff --git a/lab/control/ui/index.html b/lab/control/ui/index.html index 8dfc6a6..cf43595 100644 --- a/lab/control/ui/index.html +++ b/lab/control/ui/index.html @@ -460,6 +460,12 @@

The disclaimer

no reason to churn it — so the module hands itself over on window and says when it is ready. A browser with no WebGL never gets here and the page is the flat drawing, which is what it was before any of this. --> + + diff --git a/lab/control/ui/style.css b/lab/control/ui/style.css index 2561967..161b21c 100644 --- a/lab/control/ui/style.css +++ b/lab/control/ui/style.css @@ -742,6 +742,96 @@ pre.out.live { max-height: 38vh; overflow-y: auto; } .hud-rung.delivered { border-top-color: var(--ok); background: var(--ok-soft); } .hud-rung.delivered .t { color: var(--ok); font-weight: 600; } +/* --------------------------------------------------------------------------- + Widgets and slots + + Four docks around the board, holding cards a theme asked for. hud/slots.js + builds this layer and removes it again when a theme docks nothing, so there + is no empty element over the board in the ordinary case. + + The layer sits inside the gap the rails leave: --rail-* is measured from + the live rails, the bar and the ladder, so the docks follow a rail being + folded without knowing that rails exist. It takes no pointer events itself + — only the cards do — because a transparent layer over the board is a layer + that would eat every drag aimed at the drawing underneath it. + + --rail-* and not --pad-*, and that is the one thing here worth being + careful about. --pad-* is the same measurement plus what these cards are + covering — it is what moves the gauges and the verdict out of a card's way + — so a dock positioned from it would be a dock that moves every time it is + measured. app.js writes both and says why. + + The docks themselves have no background. A dock with no cards in it is + nothing at all, which is what makes a one-card layout cost the reader one + card's worth of board rather than a frame around the whole window. + --------------------------------------------------------------------------- */ +.slots { + position: absolute; z-index: 22; + inset: calc(var(--rail-t, var(--bar-h)) + var(--hud-gap)) + calc(var(--rail-r, 0px) + var(--hud-gap)) + calc(var(--rail-b, 0px) + var(--hud-gap)) + calc(var(--rail-l, 0px) + var(--hud-gap)); + display: grid; + /* The corners go to the rows: a top strip that spans the full width reads as + a strip, and a left column that starts under it reads as a column. The + alternative — columns owning the corners — makes the top card look like a + third rail with a gap in it. */ + grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-rows: auto minmax(0, 1fr) auto; + grid-template-areas: + "top top top" + "left . right" + "bottom bottom bottom"; + gap: var(--hud-gap); + pointer-events: none; +} + +.dock { display: flex; gap: var(--hud-gap); min-width: 0; min-height: 0; } +.dock-top { grid-area: top; } +.dock-bottom { grid-area: bottom; align-items: flex-end; } +.dock-left { grid-area: left; flex-direction: column; } +.dock-right { grid-area: right; flex-direction: column; align-items: flex-end; } + +/* A column dock is as wide as the widest card in it and no wider, and a row + dock is as tall as its tallest. The cards are what have a size; the dock is + where they are. */ +.dock-left, .dock-right { width: max-content; max-width: 300px; } + +.card { + pointer-events: auto; + display: grid; grid-template-rows: auto minmax(0, 1fr); + min-width: 0; min-height: 0; + border-radius: var(--radius); overflow: hidden; +} + +/* A card in a column may be tall; a card in a row may not. The scroll is on + the body so the header stays readable while its content moves, which is the + same arrangement the rails use. */ +.dock-left .card, .dock-right .card { width: 260px; max-height: 100%; } +.dock-top .card, .dock-bottom .card { max-width: 100%; } + +.card-head { + display: flex; align-items: baseline; gap: 6px; + padding: 6px 10px 4px; + border-bottom: 1px solid var(--border); +} +.card-t { + font-family: var(--mono); font-size: 10px; + letter-spacing: .08em; text-transform: uppercase; + color: var(--text-faint); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.card-body { padding: 8px 10px; min-height: 0; overflow: auto; } + +/* A widget that did not load says so where the reader is already looking. + Everything else on this page fails quietly on purpose — a set-piece that + throws must not take the board down — but a card is a box with a name on + it, so an empty one is a visible question already, and the console is a + poor place to answer it. */ +.card-body.card-dead { + font-family: var(--mono); font-size: 11px; color: var(--danger); +} + /* --------------------------------------------------------------------------- The fallback @@ -804,6 +894,24 @@ pre.out.live { max-height: 38vh; overflow-y: auto; } not float, and a pinch on a page this size is the browser's. */ .railtog, .viewpad { display: none; } .hud-ladder { height: auto; } + + /* The cards stack at the foot of the page, for the same reason the rails + stop floating: there is no margin left to overlay honestly. They come + after the readouts because that is where slots.js appends the layer, and + because a card is an extra the reader chose rather than the instrument. */ + .slots { + position: static; inset: auto; display: block; gap: 0; + pointer-events: auto; + } + .dock { display: block; } + .dock-left, .dock-right { width: auto; max-width: none; } + .card { + border-radius: 0; box-shadow: none; backdrop-filter: none; + border-width: 1px 0 0 0; background: var(--bg); + } + .dock-left .card, .dock-right .card, + .dock-top .card, .dock-bottom .card { width: auto; max-width: none; max-height: none; } + .card-body { overflow: visible; } } @media (max-width: 720px) { diff --git a/lab/control/widgets.go b/lab/control/widgets.go new file mode 100644 index 0000000..210b9c0 --- /dev/null +++ b/lab/control/widgets.go @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Hashem Aldhaheri + +package main + +import ( + "encoding/json" + "io/fs" + "log" + "path" + "sort" +) + +// --------------------------------------------------------------------------- +// Widgets and slots +// +// The page has four docks around the board — top, left, right, bottom — and a +// dock holds cards. A card is a widget: a folder under ui/hud/widgets/ with a +// widget.js in it, which is handed a div and the feed and told to draw. +// +// This file is the half that finds them, and it is deliberately the same +// shape as themes.go: walk the embedded UI at startup, skip anything +// malformed with a line at the console, serve what is left at /api/widgets. +// Adding a widget is a folder and a rebuild, not an edit to a list in three +// places — the same property panels.go and the theme store already have. +// +// What goes where is not in here. A widget says which docks it fits in; a +// theme's theme.json says which ones are actually placed and in what order. +// That split is the point of the branch: the page's furniture is data, so a +// theme can rearrange the instrument without touching index.html. +// +// The board is not in this list. It is the floor of the window rather than a +// card in a dock, app.js creates it and drives it by hand, and the slot it +// sits in is host-owned — the layer knows its rectangle so cards stay off it, +// and that is all. Making the board a widget under the same contract as these +// means the board reading the feed instead of being called, which is the next +// piece of work and not this one. A privileged contract that handed a widget +// the twenty-five method board object would freeze hud/scene.js in place, so +// there is no such contract here. +// --------------------------------------------------------------------------- + +// Widget is one entry in the registry, as /api/widgets serves it. +type Widget struct { + ID string `json:"id"` + Name string `json:"name"` + Note string `json:"note"` + // Slots are the docks this widget is willing to sit in. A placement into + // a dock that is not listed is dropped: a tape of every frame on the wire + // wants a tall column and reads as nonsense in a 90px bottom strip, and + // the widget is the thing that knows that. + Slots []string `json:"slots"` + Author string `json:"author"` +} + +// widgetsDir is where the registry lives inside the UI filesystem. Constant +// for the same reason themesDir is: the UI is //go:embed-ed, so nothing can +// put a widget anywhere else without rebuilding the binary. The control +// server holds the Docker socket and has no authentication, which makes +// loading page JavaScript off disk a real risk rather than a theoretical one. +const widgetsDir = "hud/widgets" + +// The docks a widget may ask for. "board" is not one of them — see the note +// at the top of this file. +var validSlots = map[string]bool{ + "top": true, + "left": true, + "right": true, + "bottom": true, +} + +// loadWidgets walks fsys for ui/hud/widgets/*/widget.json and returns what it +// finds, alphabetically by name. +// +// fsys is the UI sub-filesystem, so paths here start at hud/. +func loadWidgets(fsys fs.FS) []Widget { + entries, err := fs.ReadDir(fsys, widgetsDir) + if err != nil { + log.Printf("hud widgets: %s is not readable (%v), so the registry is empty", widgetsDir, err) + return nil + } + + var out []Widget + for _, e := range entries { + if !e.IsDir() { + continue + } + w, ok := readWidget(fsys, e.Name()) + if !ok { + continue + } + out = append(out, w) + } + + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// readWidget reads one folder, and says why it skipped it when it does. +// +// The id has to match the folder name because the folder name is what the +// page builds the module URL from. A widget whose json claimed a different id +// would be placed under one name and imported from another, and the failure +// would be an empty card with a 404 in the network tab and nothing in the +// console that named the theme that asked for it. +func readWidget(fsys fs.FS, dir string) (Widget, bool) { + b, err := fs.ReadFile(fsys, path.Join(widgetsDir, dir, "widget.json")) + if err != nil { + log.Printf("hud widget %q: no widget.json, skipped", dir) + return Widget{}, false + } + var w Widget + if err := json.Unmarshal(b, &w); err != nil { + log.Printf("hud widget %q: widget.json does not parse (%v), skipped", dir, err) + return Widget{}, false + } + if w.ID != dir { + log.Printf("hud widget %q: widget.json says id %q, skipped", dir, w.ID) + return Widget{}, false + } + if w.Name == "" { + log.Printf("hud widget %q: no name, skipped", dir) + return Widget{}, false + } + if _, err := fs.Stat(fsys, path.Join(widgetsDir, dir, "widget.js")); err != nil { + log.Printf("hud widget %q: no widget.js, skipped", dir) + return Widget{}, false + } + + // Required, and an empty one is fine. A widget brings its own styling + // because the alternative is style.css carrying a block per widget, which + // puts the host back in the business of knowing what exists — the thing + // this file was written to stop. Requiring the file rather than probing + // for it is what lets slots.js load it unconditionally: a widget the + // server served has a stylesheet, so there is no 404 to explain and no + // optional path to get wrong. + if _, err := fs.Stat(fsys, path.Join(widgetsDir, dir, "widget.css")); err != nil { + log.Printf("hud widget %q: no widget.css, skipped (an empty one is fine)", dir) + return Widget{}, false + } + + // An unknown dock is dropped rather than taken as a reason to drop the + // widget: a widget listing "sidebar" alongside "left" is a widget written + // against a slot name this build does not have, and the half of it that + // still makes sense is worth keeping. + var slots []string + for _, s := range w.Slots { + if !validSlots[s] { + log.Printf("hud widget %q: slot %q is not a dock, ignored", dir, s) + continue + } + slots = append(slots, s) + } + if len(slots) == 0 { + log.Printf("hud widget %q: no slot it fits in, skipped", dir) + return Widget{}, false + } + w.Slots = slots + + return w, true +} + +// resolvePlacements drops every placement that cannot be honoured, and says +// which and why. +// +// A theme is parsed without knowing what is in the registry — themes.go can +// tell that a slot name is wrong, but not that a widget is missing — so this +// is the pass that puts the two halves together. It runs once at startup, +// which means the layout the page is handed is already true: the page mounts +// what it is given and never has to decide what to do about a card it cannot +// build. +// +// The themes are returned rather than mutated in place so the caller's +// assignment is where the pruning visibly happens. +func resolvePlacements(themes []Theme, widgets []Widget) []Theme { + fits := map[string]map[string]bool{} + for _, w := range widgets { + slots := map[string]bool{} + for _, s := range w.Slots { + slots[s] = true + } + fits[w.ID] = slots + } + + out := make([]Theme, 0, len(themes)) + for _, th := range themes { + var kept []Placement + for _, p := range th.Layout { + slots, known := fits[p.Widget] + if !known { + log.Printf("hud theme %q: widget %q is not embedded, placement dropped", th.ID, p.Widget) + continue + } + if !slots[p.Slot] { + log.Printf("hud theme %q: widget %q does not fit the %q dock, placement dropped", th.ID, p.Widget, p.Slot) + continue + } + kept = append(kept, p) + } + th.Layout = kept + out = append(out, th) + } + return out +} diff --git a/lab/control/widgets_test.go b/lab/control/widgets_test.go new file mode 100644 index 0000000..e48f4f9 --- /dev/null +++ b/lab/control/widgets_test.go @@ -0,0 +1,285 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Hashem Aldhaheri + +package main + +import ( + "io/fs" + "strings" + "testing" + "testing/fstest" +) + +// Same split as themes_test.go: the questions of the form "is what shipped +// well-formed" run against the real embedded folders, and the questions about +// how a malformed one is handled run against a synthetic tree, because the +// embedded one is — and should stay — correct. + +func TestEmbeddedWidgetsAreWellFormed(t *testing.T) { + got := loadWidgets(embeddedUI(t)) + if len(got) == 0 { + t.Fatal("no widgets were embedded; ui/hud/widgets should hold at least one") + } + + seen := map[string]bool{} + for _, w := range got { + if seen[w.ID] { + t.Errorf("widget %q appears twice", w.ID) + } + seen[w.ID] = true + + if w.Name == "" { + t.Errorf("widget %q has no name, so its card would have an empty header", w.ID) + } + // The note is the only thing somebody browsing the registry to write + // a theme has to go on. + if w.Note == "" { + t.Errorf("widget %q has no note", w.ID) + } + if len(w.Slots) == 0 { + t.Errorf("widget %q fits in no dock, so nothing could place it", w.ID) + } + for _, s := range w.Slots { + if !validSlots[s] { + t.Errorf("widget %q claims slot %q, which is not a dock", w.ID, s) + } + } + } +} + +// The seam, asserted. A widget brings its own stylesheet, which is what keeps +// style.css from carrying a block per widget — and the price of that is that a +// widget could repaint the whole page from inside its own folder. Every +// selector is anchored to the widget's own card, and this is what says so. +// +// It is the same test, for the same reason, as the one holding a theme to the +// nine --board-* properties: the rule is only a rule while something fails +// when it is broken. +func TestWidgetStylesheetsAreScopedToTheirOwnCard(t *testing.T) { + ui := embeddedUI(t) + for _, w := range loadWidgets(ui) { + b, err := fs.ReadFile(ui, widgetsDir+"/"+w.ID+"/widget.css") + if err != nil { + t.Errorf("widget %q: reading widget.css: %v", w.ID, err) + continue + } + anchor := `[data-widget="` + w.ID + `"]` + for _, sel := range selectors(string(b)) { + if !strings.Contains(sel, anchor) { + t.Errorf("widget %q: selector %q is not anchored to %s, so it styles a page it does not own", + w.ID, sel, anchor) + } + } + } +} + +// Every placement in every embedded theme builds something. +// +// This is the check that matters most in this file, because the failure it +// catches is silent in a browser: a theme naming a widget that is not there +// gets a layout with that card quietly missing, and the reader has no way to +// tell that from a theme that never asked for it. Running the real loaders +// against the real folders is the only way to know that what shipped agrees +// with itself. +func TestEmbeddedThemeLayoutsAllResolve(t *testing.T) { + ui := embeddedUI(t) + widgets := loadWidgets(ui) + asked := loadThemes(ui) + kept := resolvePlacements(asked, widgets) + + if len(asked) != len(kept) { + t.Fatalf("resolvePlacements returned %d themes, want the %d it was given", len(kept), len(asked)) + } + for i := range asked { + if len(asked[i].Layout) == len(kept[i].Layout) { + continue + } + t.Errorf("theme %q asked for %d cards and only %d can be built; the console says which", + asked[i].ID, len(asked[i].Layout), len(kept[i].Layout)) + } +} + +// The base theme docks nothing, for the same reason its stylesheet overrides +// nothing: it is the page as it was before there was a store, and it is where +// a saved id falls back to. A fallback that rearranges the furniture is not a +// fallback. +func TestBaseThemeDocksNothing(t *testing.T) { + for _, th := range loadThemes(embeddedUI(t)) { + if th.ID != baseTheme { + continue + } + if len(th.Layout) != 0 { + t.Errorf("%q docks %d cards; it is meant to be the page with no cards on it", + baseTheme, len(th.Layout)) + } + return + } + t.Fatalf("%q was not found", baseTheme) +} + +// --------------------------------------------------------------------------- +// What happens to a malformed one +// --------------------------------------------------------------------------- + +func TestWidgetIDMustMatchItsFolder(t *testing.T) { + got := loadWidgets(widgetTree(map[string]string{ + "good": `{"id":"good","name":"Good","slots":["left"]}`, + "liar": `{"id":"somethingelse","name":"Liar","slots":["left"]}`, + })) + if len(got) != 1 || got[0].ID != "good" { + t.Fatalf("got %v, want only the folder whose id matches it", got) + } +} + +func TestWidgetWithoutItsFilesIsSkipped(t *testing.T) { + tree := fstest.MapFS{ + widgetsDir + "/nojs/widget.json": {Data: []byte(`{"id":"nojs","name":"No JS","slots":["left"]}`)}, + widgetsDir + "/nojs/widget.css": {Data: []byte("")}, + widgetsDir + "/nocss/widget.json": {Data: []byte(`{"id":"nocss","name":"No CSS","slots":["left"]}`)}, + widgetsDir + "/nocss/widget.js": {Data: []byte("")}, + } + if got := loadWidgets(tree); len(got) != 0 { + t.Fatalf("got %v, want nothing: the page would ask for a file that is not there", got) + } +} + +func TestUnparseableWidgetIsSkippedNotFatal(t *testing.T) { + got := loadWidgets(widgetTree(map[string]string{ + "broken": `{ this is not json`, + "fine": `{"id":"fine","name":"Fine","slots":["left"]}`, + })) + if len(got) != 1 || got[0].ID != "fine" { + t.Fatalf("got %v, want the one good widget: a broken extra must not take the registry with it", got) + } +} + +// An unknown dock is dropped from the list and the widget is kept, because a +// widget listing one slot this build does not have and one it does is still +// placeable in the one it does. A widget left with no slot at all is dropped: +// nothing could ever place it, and keeping it would put a row in the registry +// that is an invitation to a placement that always fails. +func TestUnknownSlotIsDroppedAndAnEmptyListSkipsTheWidget(t *testing.T) { + got := loadWidgets(widgetTree(map[string]string{ + "half": `{"id":"half","name":"Half","slots":["sidebar","left"]}`, + "none": `{"id":"none","name":"None","slots":["sidebar"]}`, + })) + if len(got) != 1 || got[0].ID != "half" { + t.Fatalf("got %v, want only the widget with a real slot left", got) + } + if len(got[0].Slots) != 1 || got[0].Slots[0] != "left" { + t.Errorf("slots = %v, want just the one this build has", got[0].Slots) + } +} + +// A placement aimed at a slot that is not a dock cannot become a card however +// the registry turns out, so themes.go drops it while reading the folder. +func TestThemeLayoutDropsAnUnknownSlot(t *testing.T) { + tree := fstest.MapFS{ + themesDir + "/t/theme.json": {Data: []byte(`{"id":"t","name":"T","ground":"any","layout":[ + {"widget":"wire","slot":"left"}, + {"widget":"wire","slot":"middle"}, + {"widget":"","slot":"left"} + ]}`)}, + themesDir + "/t/theme.css": {Data: []byte("")}, + } + got := loadThemes(tree) + if len(got) != 1 { + t.Fatalf("got %v, want the theme kept", got) + } + if len(got[0].Layout) != 1 || got[0].Layout[0].Slot != "left" { + t.Fatalf("layout = %v, want only the placement aimed at a real dock", got[0].Layout) + } +} + +// The other half of the same question: the slot is real, but the widget is not +// embedded or does not fit the dock that was asked for. +func TestResolvePlacementsDropsWhatCannotBeBuilt(t *testing.T) { + widgets := []Widget{{ID: "tape", Name: "Tape", Slots: []string{"left", "right"}}} + themes := []Theme{{ID: "t", Name: "T", Layout: []Placement{ + {Widget: "tape", Slot: "left"}, + {Widget: "tape", Slot: "top"}, // real widget, wrong dock + {Widget: "ghost", Slot: "left"}, // not embedded + }}} + + got := resolvePlacements(themes, widgets) + if len(got) != 1 { + t.Fatalf("got %d themes, want 1", len(got)) + } + if len(got[0].Layout) != 1 { + t.Fatalf("layout = %v, want only the placement that can be built", got[0].Layout) + } + if got[0].Layout[0].Slot != "left" { + t.Errorf("kept %q, want the left one", got[0].Layout[0].Slot) + } + + // The themes it was handed are not modified, so a caller that kept a + // reference to the unpruned list still has one. + if len(themes[0].Layout) != 3 { + t.Errorf("the input theme was mutated: layout is now %v", themes[0].Layout) + } +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +// widgetTree builds a registry from id -> widget.json, with the two required +// files present so the only thing under test is the manifest. +func widgetTree(manifests map[string]string) fstest.MapFS { + tree := fstest.MapFS{} + for dir, json := range manifests { + tree[widgetsDir+"/"+dir+"/widget.json"] = &fstest.MapFile{Data: []byte(json)} + tree[widgetsDir+"/"+dir+"/widget.js"] = &fstest.MapFile{Data: []byte("")} + tree[widgetsDir+"/"+dir+"/widget.css"] = &fstest.MapFile{Data: []byte("")} + } + return tree +} + +// selectors returns every selector in a stylesheet: the text before each +// opening brace, split on commas and trimmed. +// +// At-rules are skipped rather than checked — `@media (max-width: 600px)` is +// not a selector and the selectors inside it are returned on their own, which +// is what the caller wants to look at. Declarations are skipped by taking only +// the text after the last `;` or `}`, which is where a selector starts. +func selectors(css string) []string { + var out []string + rest := stripCSSComments(css) + + for { + open := strings.Index(rest, "{") + if open < 0 { + return out + } + head := rest[:open] + rest = rest[open+1:] + + if cut := strings.LastIndexAny(head, ";}"); cut >= 0 { + head = head[cut+1:] + } + for _, sel := range strings.Split(head, ",") { + sel = strings.TrimSpace(sel) + if sel == "" || strings.HasPrefix(sel, "@") { + continue + } + out = append(out, sel) + } + } +} + +func stripCSSComments(s string) string { + var b strings.Builder + for { + start := strings.Index(s, "/*") + if start < 0 { + b.WriteString(s) + return b.String() + } + b.WriteString(s[:start]) + end := strings.Index(s[start+2:], "*/") + if end < 0 { + return b.String() + } + s = s[start+2+end+2:] + } +} From d648564b446f6fb69b9c427ed5b76b371ceb28a3 Mon Sep 17 00:00:00 2001 From: Hashem Aldhaheri <158606+aenawi@users.noreply.github.com> Date: Sun, 13 Sep 2026 05:59:34 +0400 Subject: [PATCH 4/7] Write the wire to a file, and play it back with no lab (#80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branch 4 of the HUD sequence, and the one the other three were for. A recording is a `.jsonl` of /api/stream/hud — one JSON object per line — and `ui/replay.html` reads one and draws it on the same board, with the same set-pieces and the same cards a theme docked, asking no server anything. Two ways to make one. `make record` curls the wire and strips the SSE framing; the **record** button in the live page's bar writes down the frames already arriving, so a recording is exactly what that page saw — including the capture somebody had switched on. It opens no connection and starts no process in a container, which is why it sits above the busy guard: the minute worth recording is the minute every other button is disabled. The opening `hello` grew a `page`: the /api/meta payload, the theme store and the widget registry. That is what makes a file complete — the player has no server to ask, so a recording carrying only events could not say which machines to cut doorways for or where the cards go. `metaPayload()` is now one function rather than two literals, because the copy that drifts is the one in the recording and nothing would say so. The player is a viewer and deliberately not a control surface: no switches, no attacks, because a recording has nothing to press them against. What is there is a transport, and a list of the moments — every Result the lab returned — to jump to. Three things that are wrong invisibly rather than loudly, so they are tested: - A scrub is not a fast replay. The snapshots are sent only when they changed, so seeking re-applies the ones that were standing and skips the packets and verdicts between — replaying at 60× would fire every set-piece it passed. - The hello is a header, not a moment. On a recording of a session already in progress it is older than everything else in the file. - The snapshots a recorder seeds from can be ten minutes old. They are stamped when they were written, with the measurement's own clock kept as `wasAt` — driven against the live lab, a seven-second recording claimed forty-two before this. `hud/scene.js` now owns `refs()` and `HUD_IDS`, because two pages create boards and eighteen querySelector calls copied into the second is how one quietly loses a readout. A check asserts every id is in both pages. `wire.js` holds the two lists both feed.js and replay.js read — what the wire carries, and which of those are snapshots rather than moments. A stale second copy of the latter is a player drawing yesterday's board after every scrub. The player needs an HTTP origin and not a lab: `python3 -m http.server` over `lab/control/ui` serves it with Docker not installed. It cannot be a double-click, because ` + 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; } + }; +})(); From e0b7c97b7f84aa36f9aafd669e699bb167a1ac00 Mon Sep 17 00:00:00 2001 From: Hashem Aldhaheri <158606+aenawi@users.noreply.github.com> Date: Sun, 13 Sep 2026 07:36:11 +0400 Subject: [PATCH 5/7] Measure the fourth side too, so the verdict clears a bottom-docked card (#83) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HUD overlay insets itself by whatever the furniture is covering on three sides and by a flat 12px on the fourth. Since #79 gave a theme a bottom dock, that fourth side has something under it: on Warroom the verdict panel was drawn 30px behind the tally card and the transcript — the one line on the board that is a command rather than a rendering of a number — was inside it completely. --pad-b could not simply be added to the padding. It is measured against the window, because the page verdict and the view controls have to clear the rung ladder, and the ladder sits below the viewport rather than over it. The overlay is laid out inside the viewport, so reading --pad-b there clears the ladder a second time and lifts the whole bottom stack a ladder higher than the card it only had to clear by a gap. So publish the number the camera already gets — what covers the board, not what is occupied at the foot of the window — as --pad-b-canvas, and read that. The other three sides need no second token: the viewport starts at the top-left corner of the window, so there the two rectangles agree. Measured in Chrome 147 at 1440x760 on 2026-09-13, Warroom, after a probe: before after tally card #hud-verdict 393-514 393-514 597-700 #hud-transcript 670-698 (inside) 557-585 overlay pad-b 12px 125px The default page is untouched: with nothing docked --pad-b-canvas is 0px and the padding stays at 12px, the transcript at 670-698 exactly as before. replay.html gets the same treatment, where the thing below the board is the transport rather than a card. The gauges do not move. The top row is an `auto` row above a `1fr`, so the padding comes out of the gap; at 625px — one pixel above the height where the layout floats and the question stops applying — the overlay already overflows with the old rule and both rules put the bottom stack in the same place. Closes #81 --- lab/control/ui/app.js | 18 ++++++++++++++++++ lab/control/ui/replay-app.js | 5 +++++ lab/control/ui/style.css | 10 ++++++++++ 3 files changed, 33 insertions(+) diff --git a/lab/control/ui/app.js b/lab/control/ui/app.js index 49d2f1e..74d4a59 100644 --- a/lab/control/ui/app.js +++ b/lab/control/ui/app.js @@ -391,6 +391,24 @@ root.setProperty("--pad-t", Math.round(covered.top) + "px"); /* The floating panels clear the ladder; the camera, above, does not. */ root.setProperty("--pad-b", Math.round(padBottom) + "px"); + /* The bottom edge is the only one that needs saying twice. --pad-l, -r + and -t are the same number against either rectangle, because the + viewport starts at the top-left corner of the window and the rails are + drawn over it. The bottom is where the two part company, and which one + a rule wants depends on what it is positioned inside: + + --pad-b against the window. The page verdict and anything + else that sits in .app, which the ladder is in. + --pad-b-canvas against the board. The HUD overlay is laid out + inside .viewport, whose bottom edge is already above + the ladder — so clearing the ladder a second time + lifts the verdict a whole ladder higher than the card + it only had to clear by a gap. + + Same number the camera is handed, for the same reason: it is what + covers the board rather than what is occupied at the foot of the + window. */ + root.setProperty("--pad-b-canvas", Math.round(covered.bottom) + "px"); if (board && board.setViewInset) board.setViewInset(covered); } diff --git a/lab/control/ui/replay-app.js b/lab/control/ui/replay-app.js index d9715da..e763e9b 100644 --- a/lab/control/ui/replay-app.js +++ b/lab/control/ui/replay-app.js @@ -218,6 +218,11 @@ root.setProperty("--pad-r", Math.round(covered.right) + "px"); root.setProperty("--pad-t", Math.round(covered.top) + "px"); root.setProperty("--pad-b", Math.round(padBottom) + "px"); + /* The camera's answer, published, because the HUD overlay is laid out + inside the viewport and so wants the same one: what covers the *board* + — here the transport and whatever the docks hide — and not the ladder + below it. Why only this edge needs a second token is in app.js. */ + root.setProperty("--pad-b-canvas", Math.round(covered.bottom) + "px"); if (board && board.setViewInset) board.setViewInset(covered); } diff --git a/lab/control/ui/style.css b/lab/control/ui/style.css index 26eed98..d49745d 100644 --- a/lab/control/ui/style.css +++ b/lab/control/ui/style.css @@ -613,6 +613,16 @@ pre.out.live { max-height: 38vh; overflow-y: auto; } position: absolute; inset: 0; pointer-events: none; padding: 12px calc(var(--pad-r, 0px) + 12px) 12px calc(var(--pad-l, 0px) + 12px); padding-top: calc(var(--pad-t, 0px) + 12px); + /* The fourth side, and it reads a different token from the other three. + Those are the same number against the window or against the board; this + one is not, because the rung ladder is below the viewport rather than + over it. --pad-b clears the ladder — right for .verdict, which is in + .app — and this box is inside .viewport, whose bottom edge is already + above the ladder. --pad-b-canvas is the number measured there, so the + verdict and the transcript stop on top of a bottom-docked card rather + than under it, and without a whole ladder of dead air when nothing is + docked at all. */ + padding-bottom: calc(var(--pad-b-canvas, 0px) + 12px); display: grid; grid-template-rows: auto 1fr auto; gap: 8px; transition: padding .18s ease; } From 01a2f9b1ada9cdb98b0a7ce91d473d44b441c14b Mon Sep 17 00:00:00 2001 From: Hashem Aldhaheri <158606+aenawi@users.noreply.github.com> Date: Sun, 13 Sep 2026 08:08:34 +0400 Subject: [PATCH 6/7] Make the board read the feed, so two pages stop mapping it twice (#84) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mapping from a frame on the wire to a drawing on the board existed in two places. app.js made it from the replies to its own POSTs; replay-app.js made it from the frames in a file. Same wire, same four types, two copies — and a copy that drifts is a recording that stops looking like the session it recorded. hud/driver.js is now the one copy. It takes { board, feed } and subscribes: state to setState, a verdict that named an action to its set-piece, flow to probe, and packet to whatever the shot asked to watch. `feed` is the widget contract's feed and nothing more, so window.LabFeed satisfies it and so does the player replay.js builds out of a file — which is what lets the same three listeners serve a lab and a recording. What the pages keep is what is genuinely theirs. app.js keeps the POSTs, the busy guard, the text trace, the tabs and the rails; it reads the lab back after a change and hands the snapshot to driver.state() rather than to the board, because a switch has to move the picture without waiting for the poll. replay-app.js keeps the transport, the moments and the verdict line — the sentence lands even with no WebGL to draw the shot on. Two rules the copies agreed on, now owned in one place and pinned by tests: `set` and `preset` draw no shot. A switch moves the board by changing the lab and letting the next snapshot redraw it; the board has never rendered one as an event, and a driver that played a shot per verdict would draw something no reader ever saw. A held frame outranks a snapshot. run() calls hold() the moment a shot starts and setState repaints the standing board underneath it, so a snapshot arriving under a held frame waits — and it is kept rather than dropped, so it goes on before the next shot rather than leaving the board standing on the configuration from before the last one. `stat` is the heartbeat that releases it when nothing else follows, which is the case where a switch flipped under a held frame used to half-wipe the frame on the live page and be dropped outright on the player. Driven against the real lab on 2026-09-13, control rebuilt from this branch: a probe drew from the `flow` frame (rung 5, "direct · punched"), scan-public and scan-tailnet drew their set-pieces from `verdict` frames, a switch flipped under scan-public's held frame left the frame intact, and the flat view still draws nothing while the text trace lands. A 39-frame recording of that session, opened in replay.html, drew the same sniff and probe off the same driver, twice, with an empty console. Closes #82 --- lab/checks/driver.test.mjs | 238 +++++++++++++++++++++++++++++++++++ lab/checks/syntax.mjs | 1 + lab/control/ui/app.js | 135 ++++++++++---------- lab/control/ui/hud/driver.js | 187 +++++++++++++++++++++++++++ lab/control/ui/hud/slots.js | 8 +- lab/control/ui/index.html | 3 +- lab/control/ui/replay-app.js | 102 ++++++--------- lab/control/ui/replay.html | 3 +- lab/control/widgets.go | 18 +-- 9 files changed, 551 insertions(+), 144 deletions(-) create mode 100644 lab/checks/driver.test.mjs create mode 100644 lab/control/ui/hud/driver.js diff --git a/lab/checks/driver.test.mjs b/lab/checks/driver.test.mjs new file mode 100644 index 0000000..b4d3be7 --- /dev/null +++ b/lab/checks/driver.test.mjs @@ -0,0 +1,238 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + Copyright (C) 2026 Hashem Aldhaheri */ + +/* ============================================================ + Does the board read the wire the way both pages used to read it? + + hud/driver.js is the one copy of a mapping that used to be two: app.js + made it from the replies to its own POSTs and replay-app.js made it from + the frames in a file. Merging them is only worth anything if the merged + version keeps the two rules the copies agreed on, and those two rules are + exactly the ones that fail silently. + + `set` and `preset` draw no shot. On the live page a switch flip moves + the board by changing the lab and letting the next snapshot redraw it. + A driver that played a shot per verdict would draw something no reader + ever saw — and it would look like a feature, not a bug. + + A held frame outranks a snapshot. run() calls hold() the moment a shot + starts, and setState repaints the standing board underneath it: back + goes the machine expired-key moved, off comes the dim the shot drew + over. A snapshot arriving under a held frame therefore waits. + + Neither throws when it is wrong. The board simply says something that did + not happen, which is the failure this whole repository is against. + + The board and the feed are both fakes here, and deliberately: what is + being tested is a mapping between two objects, and a real board needs a + GPU. run() is injected for the same reason — the real one is imported by + the module either way, and this file asserts which id it was handed and + when, not what three.js did with it. + ============================================================ */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +let driver = null, linkError = "", browserOnly = ""; +try { + driver = await import("../control/ui/hud/driver.js"); +} catch (e) { + /* Same split as setpieces.test.mjs: a SyntaxError at link time is an + import naming something that is not exported, and that is ours. */ + if (e && e.name === "SyntaxError") linkError = e.message; + else browserOnly = "hud/ does not evaluate outside a browser here: " + e.message; +} +const needsBrowser = driver ? false : (browserOnly || false); + +test("hud/driver.js links — every import names something that is exported", () => { + assert.equal(linkError, "", "hud/driver.js failed to link: " + linkError); +}); + +/* A board that records rather than draws. `holding` is a plain field so a + test can put a held frame up without running a set-piece. */ +function fakeBoard() { + return { + holding: false, + calls: [], + setState(s) { this.calls.push(["setState", s.tag]); }, + probe(res, isAttack) { this.calls.push(["probe", res.tag, !!isAttack]); }, + report(res, isAttack) { this.calls.push(["report", res.tag, !!isAttack]); } + }; +} + +/* A feed that is only a fan-out. want() hands back a release the same way + the real one does, and `open` is how many of them have not been called — + which on the real feed is how many tcpdumps are running. */ +function fakeFeed() { + const listeners = {}; + const feed = { + open: 0, + on(type, fn) { + (listeners[type] || (listeners[type] = [])).push(fn); + return () => { + const i = listeners[type].indexOf(fn); + if (i >= 0) listeners[type].splice(i, 1); + }; + }, + want() { + feed.open++; + let done = false; + return () => { if (!done) { done = true; feed.open--; } }; + }, + send(type, data) { (listeners[type] || []).slice().forEach((fn) => fn(data)); }, + count(type) { return (listeners[type] || []).length; } + }; + return feed; +} + +/* A set-piece runner that says yes to the ids it was given and no to the + rest, which is what the real one does with SETPIECES. */ +function fakeRun(known) { + const ran = []; + const run = (board, id) => { if (known.indexOf(id) < 0) return false; ran.push(id); return true; }; + run.ran = ran; + return run; +} + +const verdict = (kind, id, tag) => ({ kind, id, result: { tag: tag || id } }); + +test("a verdict that named an action plays its set-piece", { skip: needsBrowser }, () => { + const board = fakeBoard(), feed = fakeFeed(), run = fakeRun(["sniff"]); + driver.create({ board, feed, run }); + + feed.send("verdict", verdict("action", "sniff")); + assert.deepEqual(run.ran, ["sniff"]); + assert.equal(board.calls.length, 0, "the set-piece drew, so nothing else should have"); +}); + +test("a switch and a configuration draw no shot at all", { skip: needsBrowser }, () => { + /* The rule that is easiest to get wrong by being helpful. Both of these + carry a real Result and both of them are on the wire; neither is + something the board has ever rendered as an event. */ + const board = fakeBoard(), feed = fakeFeed(), run = fakeRun(["sniff"]); + driver.create({ board, feed, run }); + + feed.send("verdict", verdict("set", "vps.allowPublic22")); + feed.send("verdict", verdict("preset", "hardened")); + + assert.deepEqual(run.ran, [], "a set or a preset is not a shot"); + assert.equal(board.calls.length, 0, "and it is not a fallback either"); +}); + +test("an action with no set-piece falls back rather than leaving the last frame up", + { skip: needsBrowser }, () => { + const board = fakeBoard(), feed = fakeFeed(), run = fakeRun([]); + const shots = []; + driver.create({ board, feed, run, onShot: (s) => shots.push(s) }); + + /* Two machines: ride it. */ + feed.send("verdict", { kind: "action", id: "new-one", + result: { tag: "a", from: "evil-box", to: "lab-vps" } }); + /* No machines named: the board can only report it. */ + feed.send("verdict", { kind: "action", id: "another", result: { tag: "b" } }); + + assert.deepEqual(board.calls, [["probe", "a", true], ["report", "b", true]]); + assert.deepEqual(shots.map((s) => s.played), [false, false], + "the page is told, because that is when it opens the text trace"); + }); + +test("a probe is a flow, and it is not an attack", { skip: needsBrowser }, () => { + const board = fakeBoard(), feed = fakeFeed(), run = fakeRun([]); + driver.create({ board, feed, run }); + + feed.send("flow", { kind: "probe", id: "probe", result: { tag: "p" } }); + assert.deepEqual(board.calls, [["probe", "p", false]]); +}); + +test("a snapshot under a held frame waits, and goes on before the next shot", + { skip: needsBrowser }, () => { + /* The whole of the rule, in the order it happens: a shot holds a frame, + the three-second poll lands underneath it, and the frame survives. Then + the next shot starts, and it starts on the configuration the poll + carried rather than on the one standing before the last shot. */ + const board = fakeBoard(), feed = fakeFeed(), run = fakeRun(["sniff", "replay"]); + driver.create({ board, feed, run }); + + feed.send("state", { tag: "first" }); + assert.deepEqual(board.calls, [["setState", "first"]]); + + board.holding = true; /* a set-piece is up */ + feed.send("state", { tag: "second" }); + assert.deepEqual(board.calls, [["setState", "first"]], + "the held frame is the answer to a question somebody asked"); + + feed.send("verdict", verdict("action", "replay")); + assert.deepEqual(board.calls, [["setState", "first"], ["setState", "second"]], + "and the shot draws over the configuration that arrived, not the old one"); + }); + +test("a snapshot that waited goes on at the next tick once the frame is gone", + { skip: needsBrowser }, () => { + /* Without this a reader who flips a switch under a held frame and then + runs nothing else keeps a board drawn from before the flip. `stat` is + the only source sent every tick whether or not anything moved, which + is why it is the one that says the frame has since been cleared. */ + const board = fakeBoard(), feed = fakeFeed(), run = fakeRun([]); + const d = driver.create({ board, feed, run }); + + board.holding = true; + d.state({ tag: "flipped" }); /* the page read the lab back */ + assert.equal(board.calls.length, 0); + + feed.send("stat", {}); + assert.equal(board.calls.length, 0, "still held, so it still waits"); + + board.holding = false; + feed.send("stat", {}); + assert.deepEqual(board.calls, [["setState", "flipped"]]); + }); + +test("one watch at a time, and the last one is released", { skip: needsBrowser }, () => { + /* A set-piece that keeps reading after its frame has gone draws packets + that belong to something else, and a want() that is never released + leaves a tcpdump running inside a container. */ + const board = fakeBoard(), feed = fakeFeed(); + let seen = 0; + const run = (b, id, res, ctx) => { + ctx.watch({ capture: "evil-box" }, "packet", () => { seen++; }); + return true; + }; + const d = driver.create({ board, feed, run }); + + feed.send("verdict", verdict("action", "one")); + feed.send("packet", { line: "x" }); + assert.equal(seen, 1); + assert.equal(feed.count("packet"), 1); + + assert.equal(feed.open, 1); + + feed.send("verdict", verdict("action", "two")); + assert.equal(feed.count("packet"), 1, "the first shot's listener went with it"); + assert.equal(feed.open, 1, "and so did its request for the capture"); + + d.destroy(); + assert.equal(feed.count("packet"), 0); + assert.equal(feed.open, 0, "nothing is left running inside a container"); + assert.equal(feed.count("state"), 0, "destroy() drops every subscription"); +}); + +test("a page with no feed still gets a board that draws the configuration", + { skip: needsBrowser }, () => { + /* feed.js not loading must not also cost the board. The live page reads + the lab back over the API on every change and hands the snapshot + straight to state(); what is lost is the shots, which are on the wire. */ + const board = fakeBoard(); + const d = driver.create({ board, feed: null, run: fakeRun([]) }); + d.state({ tag: "only" }); + assert.deepEqual(board.calls, [["setState", "only"]]); + d.stop(); + d.destroy(); + }); + +test("no board is a driver that does nothing rather than one that throws", + { skip: needsBrowser }, () => { + const d = driver.create({ feed: fakeFeed() }); + d.state({ tag: "x" }); + d.stop(); + d.destroy(); + }); diff --git a/lab/checks/syntax.mjs b/lab/checks/syntax.mjs index a73ec65..f07d1e2 100644 --- a/lab/checks/syntax.mjs +++ b/lab/checks/syntax.mjs @@ -54,6 +54,7 @@ const FILES = [ ["lab/control/ui/hud/reading.js", "module"], ["lab/control/ui/hud/themes.js", "module"], ["lab/control/ui/hud/slots.js", "module"], + ["lab/control/ui/hud/driver.js", "module"], ["assets/app.js", "commonjs"], ["assets/anim.js", "commonjs"], ["assets/nav.js", "commonjs"], diff --git a/lab/control/ui/app.js b/lab/control/ui/app.js index 74d4a59..61459e7 100644 --- a/lab/control/ui/app.js +++ b/lab/control/ui/app.js @@ -114,45 +114,31 @@ Everything below works whether or not that ever happens: no board means the flat drawing and the packets tab, which is what this page was. */ var board = null, view = "board", boardBroken = false; - var hudStream = null; + + /* What turns a frame on the wire into a drawing. hud/driver.js owns that + mapping and owns it for both pages: this one and replay.html made it + separately against the same wire until they did not have to. + + So nothing below plays a shot, and nothing below calls setState except + through driver.state() — which is the same door the wire's snapshots come + in by, and the reason the held-frame rule only has to be written once. + What is left here is what is genuinely about this page: the POSTs, the + text trace, the tabs and the rails. */ + var driver = null; /* The overlay's elements come from hud/scene.js, which is what the board needs them for — replay.html creates a board too, and the eighteen lookups were the kind of list that ends up in two places with one of them a readout short. */ - /* One watch at a time for the board, dropped the moment the next action - starts. A set-piece that keeps reading after its shot has gone is a - set-piece drawing frames that belong to something else. - - A set-piece used to be handed a URL and left to open it. Now it says what - it wants to watch and the feed works out whether anything has to be started - for it: two things wanting the capture is one capture, and the shot ending - while the packets pane is still on takes nothing away from the pane. That - bookkeeping does not belong in a file about drawing. */ - function hudCtx() { - return { - watch: function (sources, type, onEvent) { - stopHudStream(); - if (!feed) return function () {}; - var offEvent = feed.on(type, onEvent); - var releaseWant = feed.want(sources); - var mine = { off: offEvent, release: releaseWant }; - hudStream = mine; - return function () { if (hudStream === mine) stopHudStream(); }; - } - }; - } - function stopHudStream() { - if (!hudStream) return; - var gone = hudStream; - hudStream = null; - gone.off(); - gone.release(); - } - function boardOn() { return !!board && view === "board"; } + /* A set-piece drew, or there was none and the fallback drew instead. The + board is the driver's business either way; the tab is this page's. */ + function onShot(shot) { + if (!shot.played) showTab("packets"); + } + /* The board needs the grants before it can cut a cell per grant, and the list of actions before it can say which ids have no set-piece. Whichever of the two arrives last calls this, because the module and /api/meta race @@ -186,7 +172,7 @@ $("#view-flat").setAttribute("aria-pressed", String(!on)); $("#view-board").disabled = boardBroken; try { localStorage.setItem("lab-view", view); } catch (e) {} - if (on) { board.resize(); if (state) board.setState(state); } + if (on) { board.resize(); if (driver) driver.state(state); } } function startBoard() { @@ -204,8 +190,22 @@ startThemes(); return; } + /* window.LabFeed rather than `feed`, which is read off window further + down this file: startBoard() runs above that line on the ready event, + and handing the driver `feed` from here would hand it undefined. The + same ordering slots.init() documents above. + + A page whose feed.js did not load still gets a driver, and the board + still draws the configuration — refresh() hands it every snapshot it + reads back. What it loses is the shots, which arrive on the wire. */ + driver = window.LabHUD.driver.create({ + board: board, + feed: window.LabFeed || null, + active: boardOn, + onShot: onShot + }); if (meta) applyMeta(); - if (state) board.setState(state); + if (state) driver.state(state); var saved = null; try { saved = localStorage.getItem("lab-view"); } catch (e) {} setView(saved === "flat" ? "flat" : "board"); @@ -1005,14 +1005,15 @@ feed.on("status", function (d) { status = d.text; paint(); }); feed.on("netcheck", function (d) { netcheck = d.text; paint(); }); feed.on("state", function (d) { + /* The panels only. The board is on the same wire and hears this frame + itself — hud/driver.js is what listens, and the held-frame rule that + used to be written out here is its. + + This is still the only thing that notices a change nobody clicked: a + container stopping, `make weak` from another terminal, a session + flipping from relay to direct. */ state = d; paintPanels(); - /* This is the only thing that notices a change nobody clicked — a - container stopping, `make weak` from another terminal, a session - flipping from relay to direct. But a held set-piece frame is the - answer to a question somebody asked, so a poll arriving afterwards - waits rather than wiping it. */ - if (board && !board.holding) board.setState(state); }); } @@ -1099,28 +1100,15 @@ state = s; paintPanels(); /* Every gate on the board renders the configuration continuously, so a - switch has to move the picture without anything being probed. */ - if (board) board.setState(state); - }); - } - - /* Every action that produces a Result ends here, so the board can never be - left blank or holding the previous action's frame under a new verdict. - Reads the lab back first, then plays the shot: the other order repaints - the board from the configuration a moment after the set-piece has drawn - what it measured, and the measurement loses. */ - function playResult(id, res) { - return refresh().then(function () { - if (!boardOn()) return; - var ran = window.LabHUD.run(board, id, res, hudCtx()); - if (ran) return; - /* No set-piece for this one yet. The board must still stop showing the - last one's held frame — an unnamed action sitting under the previous - action's verdict is worse than no picture. Ride it if it named two - machines; otherwise just report it. */ - if (res.from && res.to) board.probe(res, true); - else board.report(res, true); - showTab("packets"); + switch has to move the picture without anything being probed — and + without waiting up to three seconds for the poll to notice, which is + the only reason this page reads the lab back at all rather than + leaving the board entirely to the wire. + + Handed to the driver rather than to the board, because a snapshot + that lands under a held set-piece frame has to wait, and that rule + belongs in one place. */ + if (driver) driver.state(state); }); } @@ -1210,7 +1198,7 @@ var id = b.getAttribute("data-attack"); var label = b.querySelector("b").textContent; working(true); - stopHudStream(); + if (driver) driver.stop(); if (!boardOn()) showTab("packets"); verdict("", "Running it…"); post("/api/action", { id: id }).then(function (res) { @@ -1219,7 +1207,12 @@ and it is the record in the packets tab either way. */ trace(res, label); verdict(res.danger ? "bad" : res.ok ? "ok" : "warn", res.why, res.rung); - return playResult(id, res); + /* No shot is played from here. The same Result went out on the wire + the moment the handler was done with it, and hud/driver.js draws it + from there — on this page and on the player, off one mapping. What + is left to do is read the lab back, because the configuration the + attack left behind is what the board stands on. */ + return refresh(); }).finally(function () { working(false); }); return; } @@ -1234,7 +1227,7 @@ switch (b.id) { case "probe-btn": working(true); - stopHudStream(); + if (driver) driver.stop(); if (!boardOn()) showTab("packets"); verdict("", "Knocking, and watching the far end…"); post("/api/probe", { @@ -1242,7 +1235,9 @@ }).then(function (res) { trace(res); verdict(res.danger ? "warn" : res.ok ? "ok" : "bad", res.why, res.rung); - if (boardOn()) board.probe(res); + /* The ride is not started from here. A probe goes out on the wire + as a `flow`, and hud/driver.js is what turns one of those into a + packet crossing the board — the same line the player runs. */ }).finally(function () { working(false); }); break; @@ -1257,7 +1252,7 @@ case "reset-btn": working(true); - stopHudStream(); + if (driver) driver.stop(); if (board) board.clearScratch(); post("/api/action", { id: "reset" }).then(function (res) { trace(res, "reset"); @@ -1268,20 +1263,20 @@ case "outage-btn": working(true); - stopHudStream(); + if (driver) driver.stop(); if (!boardOn()) showTab("packets"); verdict("", "Two sessions from lab-roam, then twenty seconds with no link. " + "This one takes about a minute, and it is worth watching."); post("/api/action", { id: "outage" }).then(function (res) { trace(res, "ssh and mosh, through a 20-second outage"); verdict(res.ok ? "ok" : "warn", res.why, res.rung); - return playResult("outage", res); + return refresh(); }).finally(function () { working(false); }); break; case "rotate-btn": working(true); - stopHudStream(); + if (driver) driver.stop(); if (!boardOn()) showTab("packets"); /* It opens a session over the tailnet first when there is one to open, rotates underneath it, and then waits to see whether it kept @@ -1292,7 +1287,7 @@ post("/api/action", { id: "rotate-key" }).then(function (res) { trace(res, "rotate the key"); verdict(res.ok ? "ok" : "bad", res.why, res.rung); - return playResult("rotate-key", res); + return refresh(); }).finally(function () { working(false); }); break; diff --git a/lab/control/ui/hud/driver.js b/lab/control/ui/hud/driver.js new file mode 100644 index 0000000..3eefff8 --- /dev/null +++ b/lab/control/ui/hud/driver.js @@ -0,0 +1,187 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + Copyright (C) 2026 Hashem Aldhaheri */ + +/* ============================================================ + The board, reading the wire. + + Four types on the feed say something the board can draw, and the mapping + from one to the other is short enough to write out in full: + + 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 -> nothing on the board + flow a Result from /api/probe -> board.probe + packet one line of tcpdump -> whatever asked to watch + + That mapping used to exist twice. app.js made it from the replies to its + own POSTs and replay-app.js made it from the frames in a file, both against + the same wire, and the two had to stay identical or a recording stopped + looking like the session it recorded. This file is the one copy. Both pages + create a board, hand it here with a feed, and stop knowing how a frame + becomes a drawing. + + `feed` is the widget contract's feed and nothing more: on(), want(), and + the release functions they hand back. window.LabFeed satisfies it and so + does the player replay.js builds out of a file, which is what lets a + recording draw with no lab running. + + What this file does not do is paint the page. A verdict has a sentence in + it as well as a shot, and where that sentence goes — a live region, a + transport rail, a text trace — is the page's business and differs between + the two. The board is the whole of the responsibility here. + + Two rules that look like details and are not. Both were found while + building the player, and both are the reason a driver has to exist rather + than each page keeping its own three listeners. + + `set` and `preset` draw no shot. On the live page a switch flip moves the + board by changing the lab and letting the next `state` snapshot redraw it; + the board has never rendered a switch as an event. A driver that played a + shot per verdict would draw something no reader ever saw. + + A held frame outranks a snapshot. `run()` in setpieces.js calls hold() the + moment a shot starts, so `board.holding` is true for the whole of it, and + `setState` would repaint the standing board underneath — putting back a + machine the shot moved and undoing the dim it drew over. So a snapshot + arriving under a held frame waits. It is kept rather than dropped, and it + goes on just before the next shot clears the board, which is the order the + live page used to get by fetching the state before playing the shot. + ============================================================ */ + +import { run as runSetpiece } from "./setpieces.js"; + +/* Build a driver over one board and one feed. + + board what createBoard() returned. Required. + feed window.LabFeed, or a player. Optional: with no feed nothing is + subscribed and state() still works, which is a page whose + feed.js did not load rather than a page with no board. + active () -> boolean. Asked before a shot is drawn, never before a + snapshot. The live page has a flat view the reader can be + sitting in, and a set-piece played onto a hidden canvas is a + set-piece nobody sees the start of. Defaults to always. + onShot ({ id, result, played }) after a verdict drew something. `played` + is false when no set-piece exists for that id and the fallback + drew instead — which is when the live page opens the text trace. + run the set-piece runner, for a test. Defaults to setpieces.js'. + + Returns { state, stop, destroy }. Nothing here throws at a caller. */ +export function create(options) { + const o = options || {}; + const board = o.board; + const feed = o.feed || null; + const active = typeof o.active === "function" ? o.active : () => true; + const onShot = typeof o.onShot === "function" ? o.onShot : () => {}; + const play = typeof o.run === "function" ? o.run : runSetpiece; + + if (!board) return { state() {}, stop() {}, destroy() {} }; + + /* The one set-piece watch, dropped the moment the next one starts. A shot + that keeps reading after its frame has gone is a shot drawing packets + that belong to something else. + + A set-piece says what it wants to watch and the feed works out whether + anything has to be started for it: two things wanting the capture is one + capture, and a shot ending while the packets pane is still on takes + nothing away from the pane. */ + let watching = null; + + /* A snapshot that arrived under a held frame. See the header. */ + let pending = null; + + function stop() { + if (!watching) return; + const gone = watching; + watching = null; + gone.off(); + gone.release(); + } + + const ctx = { + watch(sources, type, onEvent) { + stop(); + if (!feed) return function () {}; + /* `want` is a no-op on a recording — whether the packets are in the + file was decided when it was made — and it is still called, because a + set-piece should not have to know which kind of feed it was given. */ + const mine = { off: feed.on(type, onEvent), release: feed.want(sources) }; + watching = mine; + return function () { if (watching === mine) stop(); }; + } + }; + + /* A configuration to draw, from the wire or from a page that has just read + the lab back over the API. Both go through here so the held-frame rule + has one home. */ + function state(s) { + if (!s) return; + if (board.holding) { pending = s; return; } + pending = null; + board.setState(s); + } + + /* Put on the snapshot a held frame made wait, if there is one. Called + before a shot clears the board, so the configuration under the new shot + is the current one rather than the one standing before the last shot. */ + function flush() { + if (!pending) return; + const s = pending; + pending = null; + board.setState(s); + } + + /* Draw a Result that named an action. Returns whether a set-piece ran. */ + function shot(id, res) { + stop(); + flush(); + if (play(board, id, res, ctx)) return true; + /* No set-piece for this one yet. The board must still stop showing the + last one's held frame — an unnamed action sitting under the previous + action's verdict is worse than no picture. Ride it if it named two + machines; otherwise just report it. */ + if (res.from && res.to) board.probe(res, true); + else board.report(res, true); + return false; + } + + const offs = []; + function listen(type, fn) { if (feed) offs.push(feed.on(type, fn)); } + + listen("state", state); + + listen("verdict", function (d) { + if (!d || !d.result) return; + if (d.kind !== "action") return; /* a switch is not an event */ + if (!active()) return; + onShot({ id: d.id, result: d.result, played: shot(d.id, d.result) }); + }); + + listen("flow", function (d) { + if (!d || !d.result) return; + if (!active()) return; + stop(); + flush(); + board.probe(d.result); + }); + + /* The wire's heartbeat. `stat` is the one polled source sent every tick + whether or not it moved, so it is the only thing that says time has + passed without also saying something changed — which is what a snapshot + waiting under a frame that has since been cleared needs to hear. Without + it a reader who flips a switch under a held frame and then runs nothing + else keeps a board drawn from the configuration before the flip. */ + listen("stat", function () { if (pending && !board.holding) flush(); }); + + return { + state: state, + stop: stop, + destroy() { + for (const off of offs) { + try { off(); } catch (e) { /* a feed already torn down */ } + } + offs.length = 0; + stop(); + pending = null; + } + }; +} diff --git a/lab/control/ui/hud/slots.js b/lab/control/ui/hud/slots.js index fcaa8b1..194f2e1 100644 --- a/lab/control/ui/hud/slots.js +++ b/lab/control/ui/hud/slots.js @@ -31,8 +31,12 @@ the feed, which is what the feed is for. The board is not a card and does not live here. It is the floor of the - window — app.js creates it, drives it, and tells it how much of itself the - furniture is covering. This file's part in that is covered(): the docks + window — the page creates it and tells it how much of itself the furniture + is covering, and hud/driver.js reads the same feed a widget reads and draws + it. That is as close to this contract as the board gets, and it is close + enough that a page no longer plays a shot by hand. + + This file's part in the rest of it is covered(): the docks report what they hide, app.js adds it to what the rails hide, and the camera aims at the gap that is left. It reports to the camera and not to the stylesheet on purpose: the docks are positioned from --pad-*, so diff --git a/lab/control/ui/index.html b/lab/control/ui/index.html index e5b086f..d7b0245 100644 --- a/lab/control/ui/index.html +++ b/lab/control/ui/index.html @@ -495,7 +495,8 @@

The disclaimer

import { SETPIECES, run, missing } from "./hud/setpieces.js"; import * as themes from "./hud/themes.js"; import * as slots from "./hud/slots.js"; - window.LabHUD = { createBoard, refs, GEOM, SETPIECES, run, missing, themes, slots }; + import * as driver from "./hud/driver.js"; + window.LabHUD = { createBoard, refs, GEOM, SETPIECES, run, missing, themes, slots, driver }; window.dispatchEvent(new Event("labhud-ready")); diff --git a/lab/control/ui/replay-app.js b/lab/control/ui/replay-app.js index e763e9b..1e6c7fd 100644 --- a/lab/control/ui/replay-app.js +++ b/lab/control/ui/replay-app.js @@ -10,20 +10,17 @@ 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. + The mapping from a frame to a drawing is not here. It was, and it was also + in app.js — two copies written against the same wire, which had to stay the + same or a recording stopped looking like the session it recorded. It is + hud/driver.js now, and both pages hand it a board and something with on() + and want() on it: window.LabFeed there, the player replay.js builds out of + a file here. That substitution is the same one the widgets rely on. + + What is left of it here is the sentence rather than the shot. A verdict has + a line of text in it as well as a picture, and where that line goes is the + page's business — the live page has a trace and five tabs, this one has one + verdict strip and a rail of moments. 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 @@ -60,7 +57,18 @@ /* ---- the board -------------------------------------------------- */ var board = null, boardBroken = false; var player = null, recording = null; - var watching = null; /* the one set-piece watch, same rule as app.js */ + var driver = null; /* hud/driver.js, over the board and the player */ + + /* The driver needs both, and they arrive in either order: the board when + the module lands, the player when a file is opened. So it is built from + whichever of the two is second, and rebuilt when a second file replaces + the first — a driver still listening to the player before it is a driver + drawing the recording somebody has closed. */ + function syncDriver() { + if (driver) { driver.destroy(); driver = null; } + if (!board || !player || !window.LabHUD.driver) return; + driver = window.LabHUD.driver.create({ board: board, feed: player }); + } function startBoard() { if (board || boardBroken || !window.LabHUD) return; @@ -79,6 +87,7 @@ /* A file may already be open: the picker and ?src= both work before the module lands, because reading a file is not the board's business. */ if (recording) applyPage(); + syncDriver(); } if (window.LabHUD) startBoard(); @@ -271,12 +280,16 @@ return; } - stopWatch(); + /* The driver first, then what it was listening to. The other order works + — a destroyed player drops its listeners and hands back a no-op want — + and it works by accident, which is not a thing to rely on twice. */ + if (driver) { driver.destroy(); driver = null; } if (player) player.destroy(); recording = rec; player = window.LabReplay.createPlayer(rec); applyPage(); + syncDriver(); listen(); paintFacts(label); paintMoments(); @@ -310,64 +323,27 @@ startThemes(); } - /* ---- one watch at a time, same rule as the live page ----------- */ - function stopWatch() { - if (!watching) return; - var gone = watching; - watching = null; - gone.off(); - gone.release(); - } + /* ---- what the page says about a frame --------------------------- + The board's half of this is hud/driver.js', and this page never sees it. + What is left is the verdict strip: every Result carries a sentence, and + the sentence lands whether or not there is a board to draw the shot on — + a reader with no WebGL still gets a readable session out of a recording. - function ctx() { - return { - watch: function (sources, type, onEvent) { - stopWatch(); - if (!player) return function () {}; - /* `want` is a no-op on a recording — whether the packets are in the - file was decided when it was made — and it is still called, because - a set-piece should not have to know which kind of feed it is - watching. If the capture was not running, no packet arrives and the - shot draws the empty tray it is written to draw. */ - var offEvent = player.on(type, onEvent); - var release = player.want(sources); - var mine = { off: offEvent, release: release }; - watching = mine; - return function () { if (watching === mine) stopWatch(); }; - } - }; - } - - /* ---- frame to drawing ------------------------------------------- */ + Which is also why a `set` and a `preset` are not filtered out here the + way they are in the driver. They draw nothing, and they still say + something: "public :22 closed" is what the reader is watching for even + though the board only shows it on the snapshot that follows. */ function listen() { - player.on("state", function (state) { - /* A held set-piece frame is the answer to a question somebody asked, so - the poll that lands three seconds later waits rather than wiping it. - The same guard app.js has, for the same reason. */ - if (board && !board.holding) board.setState(state); - }); - player.on("verdict", function (d) { if (!d || !d.result) return; var res = d.result; say(res.danger ? "bad" : res.ok ? "ok" : "warn", res.why || res.rule, res.rung); - /* Only an action draws a shot. See the header: on the live page a - switch and a configuration move the board through the state snapshot - that follows them, and never as an event of their own. */ - if (d.kind !== "action") return; - if (!board) return; - stopWatch(); - if (window.LabHUD.run(board, d.id, res, ctx())) return; - if (res.from && res.to) board.probe(res, true); - else board.report(res, true); }); player.on("flow", function (d) { - if (!d || !d.result || !board) return; + if (!d || !d.result) return; var res = d.result; say(res.danger ? "warn" : res.ok ? "ok" : "bad", res.why || res.rule, res.rung); - stopWatch(); - board.probe(res); }); player.onChange(paintTransport); diff --git a/lab/control/ui/replay.html b/lab/control/ui/replay.html index 83435e1..1ea1ce3 100644 --- a/lab/control/ui/replay.html +++ b/lab/control/ui/replay.html @@ -206,7 +206,8 @@ import { SETPIECES, run, missing } from "./hud/setpieces.js"; import * as themes from "./hud/themes.js"; import * as slots from "./hud/slots.js"; - window.LabHUD = { createBoard, refs, GEOM, SETPIECES, run, missing, themes, slots }; + import * as driver from "./hud/driver.js"; + window.LabHUD = { createBoard, refs, GEOM, SETPIECES, run, missing, themes, slots, driver }; window.dispatchEvent(new Event("labhud-ready")); diff --git a/lab/control/widgets.go b/lab/control/widgets.go index 210b9c0..8ebecd8 100644 --- a/lab/control/widgets.go +++ b/lab/control/widgets.go @@ -30,13 +30,17 @@ import ( // theme can rearrange the instrument without touching index.html. // // The board is not in this list. It is the floor of the window rather than a -// card in a dock, app.js creates it and drives it by hand, and the slot it -// sits in is host-owned — the layer knows its rectangle so cards stay off it, -// and that is all. Making the board a widget under the same contract as these -// means the board reading the feed instead of being called, which is the next -// piece of work and not this one. A privileged contract that handed a widget -// the twenty-five method board object would freeze hud/scene.js in place, so -// there is no such contract here. +// card in a dock, and the slot it sits in is host-owned — the layer knows its +// rectangle so cards stay off it, and that is all. +// +// It is no longer driven by hand, though. ui/hud/driver.js subscribes to the +// same feed a widget subscribes to and turns state, verdict, flow and packet +// into a drawing, so the board reads the wire under the same rules as +// everything in this registry. What keeps it out of the registry is only that +// it is the floor: a card gets a div in a dock, and the board gets the +// window. A privileged contract that handed a widget the twenty-five method +// board object would freeze hud/scene.js in place, so there is no such +// contract here either. // --------------------------------------------------------------------------- // Widget is one entry in the registry, as /api/widgets serves it. From be823490560651012cacbcaab1c97d7d1e36b43d Mon Sep 17 00:00:00 2001 From: Hashem Aldhaheri <158606+aenawi@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:20:48 +0400 Subject: [PATCH 7/7] Say the whole command, so a machine can always rejoin (#85) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tailscale up` refuses to run when the preferences already on the machine carry a non-default flag the command does not mention, and those preferences live in the ts-* volume, so they outlive the container. One `tailscale set --ssh` from the Tailnet policy panel was therefore enough to make every later join in the lab fail — at boot, on a configuration, on a reset, on a key rotation — with `make reset` the only way back, and no sign of why. Every `up` now carries --reset, which makes the command the whole statement of intent rather than a delta somebody can refuse. --reset also clears the one setting the lab means to keep, so setTailscaleSSH becomes the single place that owns Tailscale SSH: it sets it on every machine that is up and leaves the same fact in /lab/state/tailscale-ssh, which the entrypoint names explicitly on its own join. A machine restarted on its own now comes back the way the switch says it is. Measured on this host against tailscale 1.102.4 and headscale 0.29.3 on 2026-09-13: with RunSSH true and all three machines Stopped, applying `hardened` brings all three back to Running with RunSSH true; recreating all three containers has them join first time, no retries; all eleven audit checks pass afterwards. --- lab/control/actions.go | 89 ++++++++++++++++++++++++++++------- lab/control/attacks.go | 11 +++-- lab/control/main.go | 5 ++ lab/images/node/entrypoint.sh | 20 ++++++++ 4 files changed, 106 insertions(+), 19 deletions(-) diff --git a/lab/control/actions.go b/lab/control/actions.go index 788c7a2..c3965fa 100644 --- a/lab/control/actions.go +++ b/lab/control/actions.go @@ -121,6 +121,61 @@ func (c *Controller) PolicyText() string { return c.policyHuJSON(c.Snapshot().ACL) } +// --------------------------------------------------------------------------- +// Rejoining the tailnet +// +// `tailscale up` refuses to run at all when the preferences already on the +// machine carry a non-default flag the command does not mention, and those +// preferences live in the ts-* volume, so they outlive the container. One +// `tailscale set --ssh` from the Tailnet policy panel was therefore enough to +// make every later join in this lab fail — at boot, on a configuration, on a +// reset, on a key rotation — leaving `make reset` as the only way back. Every +// `up` below carries --reset, which says the command is the whole statement of +// intent and cannot be refused by something somebody flipped an hour ago. +// +// --reset clears the flags it was not told about, Tailscale SSH among them, so +// the lab has to put that one back itself: setTailscaleSSH is the single place +// that owns it. +// --------------------------------------------------------------------------- + +// tailnetMachines are the three machines the lab owns. evil-box joins on terms +// it stole and is deliberately not one of them. +var tailnetMachines = []string{"lab-vps", "lab-ubuntu", "lab-roam"} + +// tailscaleSSHFile records, beside the pre-auth key and in the same shared +// volume, whether Tailscale SSH is meant to be on. The entrypoint reads it so a +// machine that is restarted on its own comes back the way the switch says it +// is, rather than however `--reset` left it. +const tailscaleSSHFile = "tailscale-ssh" + +// setTailscaleSSH puts Tailscale SSH into the state s.ACL.SSH claims it is in, +// on every machine that is up, and leaves the same fact where a machine that is +// not up will read it at boot. +func (c *Controller) setTailscaleSSH(ctx context.Context, on bool) { + _ = os.WriteFile(c.StateFile(tailscaleSSHFile), + []byte(strconv.FormatBool(on)+"\n"), 0o644) + arg := "--ssh=false" + if on { + arg = "--ssh" + } + for _, m := range tailnetMachines { + if !c.lab.Running(ctx, m) { + continue + } + _, _ = c.lab.Exec(ctx, m, "tailscale", "set", arg) + } +} + +// joinCmd is the join the control server runs on a machine it owns, as a shell +// line, because the key is read on the machine rather than carried through here. +// It is a no-op on a machine that already has a session. +func joinCmd(m string) string { + return `tailscale status --json | grep -q '"BackendState": *"Running"' || ` + + `tailscale up --reset --login-server=https://headscale:8443 ` + + `--authkey="$(cat /lab/state/authkey)" --hostname=` + m + + ` --accept-routes=false --accept-dns=false --timeout=30s` +} + // --------------------------------------------------------------------------- // Setting one switch // @@ -217,13 +272,7 @@ func (c *Controller) Set(ctx context.Context, path string, value any) Result { v := boolOf() c.with(func(s *State) { s.ACL.SSH = v }) out := c.pushPolicy(ctx, res, "tailscale ssh -> "+strconv.FormatBool(v)) - for _, id := range []string{"lab-vps", "lab-ubuntu", "lab-roam"} { - arg := "--ssh=false" - if v { - arg = "--ssh" - } - _, _ = c.lab.Exec(ctx, id, "tailscale", "set", arg) - } + c.setTailscaleSSH(ctx, v) out.Cmds = append(out.Cmds, "tailscale set --ssh"+map[bool]string{true: "", false: "=false"}[v]) return out @@ -423,8 +472,11 @@ func (c *Controller) evilJoin(ctx context.Context, join bool) Result { res.Why = "no spare key was created at boot — try `make reset`" return res } - res.Cmds = []string{"tailscale up --login-server=https://headscale:8443 --authkey=tskey-LEAKED # on evil-box"} - r, err := c.lab.Exec(ctx, "evil-box", "tailscale", "up", + res.Cmds = []string{"tailscale up --reset --login-server=https://headscale:8443 --authkey=tskey-LEAKED # on evil-box"} + // --reset for the same reason the three machines have it: the exit-node + // attack leaves --advertise-exit-node in this machine's preferences, and + // without it the next join here is refused rather than run. + r, err := c.lab.Exec(ctx, "evil-box", "tailscale", "up", "--reset", "--login-server=https://headscale:8443", "--authkey="+c.untrustedKey, "--hostname=evil-box", "--accept-routes=false", "--accept-dns=false", "--timeout=30s") @@ -1416,16 +1468,22 @@ func (c *Controller) ApplyPreset(ctx context.Context, id string) Result { } res.Cmds = append(res.Cmds, "headscale policy set -f policy.hujson") - for _, m := range []string{"lab-vps", "lab-ubuntu", "lab-roam"} { + for _, m := range tailnetMachines { if !c.lab.Running(ctx, m) { continue } if p.Tailnet { - _, _ = c.lab.Sh(ctx, m, `tailscale status --json | grep -q '"BackendState": *"Running"' || tailscale up --login-server=https://headscale:8443 --authkey="$(cat /lab/state/authkey)" --hostname=`+m+` --accept-routes=false --accept-dns=false --timeout=30s`) + _, _ = c.lab.Sh(ctx, m, joinCmd(m)) } else { _, _ = c.lab.Exec(ctx, m, "tailscale", "down") } } + // After the joins, because a join that ran carried --reset and cleared this, + // and because a machine that was already up may still be carrying the last + // configuration's answer rather than this one's. + c.setTailscaleSSH(ctx, p.ACL.SSH) + res.Cmds = append(res.Cmds, "tailscale set --ssh"+ + map[bool]string{true: "", false: "=false"}[p.ACL.SSH]) if !p.Tailnet { res.Cmds = append(res.Cmds, "tailscale down # day one: the box has never heard of a tailnet") } @@ -1500,19 +1558,18 @@ func (c *Controller) Reset(ctx context.Context) Result { // takes them off it deliberately, and without this there is no way back to // the boot state except loading another configuration — which is not what a // button called "Reset everything" should mean. - for _, m := range []string{"lab-vps", "lab-ubuntu", "lab-roam"} { + for _, m := range tailnetMachines { if !c.lab.Running(ctx, m) { continue } - _, _ = c.lab.Sh(ctx, m, `tailscale status --json | grep -q '"BackendState": *"Running"' || `+ - `tailscale up --login-server=https://headscale:8443 --authkey="$(cat /lab/state/authkey)" `+ - `--hostname=`+m+` --accept-routes=false --accept-dns=false --timeout=30s`) + _, _ = c.lab.Sh(ctx, m, joinCmd(m)) } - res.Cmds = append(res.Cmds, "tailscale up --login-server=… # on any machine that was taken off") + res.Cmds = append(res.Cmds, "tailscale up --reset --login-server=… # on any machine that was taken off") d := defaultState() c.with(func(s *State) { s.ACL = d.ACL; s.Tailcat = d.Tailcat }) _ = c.applyPolicy(ctx) + c.setTailscaleSSH(ctx, d.ACL.SSH) for _, step := range []struct { path string val any diff --git a/lab/control/attacks.go b/lab/control/attacks.go index 963b0d0..97d5bf7 100644 --- a/lab/control/attacks.go +++ b/lab/control/attacks.go @@ -658,9 +658,10 @@ sleep 8 // ---- the rotation --------------------------------------------------- // --force-reauth only where there is a live registration to force. A // machine that is already out has to re-register whatever is asked of it, - // and the plain command is the one the entrypoint and the reset path have - // always used to bring one back. - up := []string{"tailscale", "up", + // and the rest of the command is the one the entrypoint and the reset path + // use to bring one back — --reset included, for the reason spelled out above + // joinCmd in actions.go. + up := []string{"tailscale", "up", "--reset", "--login-server=https://headscale:8443", "--authkey=" + key, "--hostname=lab-roam", "--accept-routes=false", "--accept-dns=false", "--timeout=30s"} if rot.wasMember { @@ -697,6 +698,10 @@ sleep 8 }) c.EnsureNodes(ctx) c.with(func(s *State) { s.Machines["lab-roam"].KeyExpired = !rot.isMember }) + // The rotation is a rotation, not a rotation plus a setting turned off + // behind the reader's back: --reset cleared Tailscale SSH here, so it goes + // back to whatever the switch says before anything is measured. + c.setTailscaleSSH(ctx, c.Snapshot().ACL.SSH) // ---- after ---------------------------------------------------------- addrAfter := "" diff --git a/lab/control/main.go b/lab/control/main.go index 596ede7..66c3878 100644 --- a/lab/control/main.go +++ b/lab/control/main.go @@ -148,6 +148,11 @@ func main() { }); ok { ctl.EnsureNodes(ctx) _ = ctl.applyPolicy(ctx) + // The policy is only half of Tailscale SSH: the other half is a + // preference on each machine, which a `tailscale up --reset` at + // boot has just cleared. Put it back from the switch, and leave the + // answer where the next boot will read it for itself. + ctl.setTailscaleSSH(ctx, ctl.Snapshot().ACL.SSH) log.Printf("all three machines are registered and tagged") } else { log.Printf("not every machine registered — check `docker compose logs lab-ubuntu`") diff --git a/lab/images/node/entrypoint.sh b/lab/images/node/entrypoint.sh index 8aa31c8..d38b3f4 100644 --- a/lab/images/node/entrypoint.sh +++ b/lab/images/node/entrypoint.sh @@ -99,15 +99,35 @@ for _ in $(seq 1 180); do sleep 1 done +# --reset is what makes this command survive a machine that has been used. +# `tailscale up` refuses outright if the preferences already on disk carry a +# non-default flag the command does not mention, and those preferences live in +# the ts-* volume, so they outlast the container. One `tailscale set --ssh` from +# the Tailnet policy panel was therefore enough to make every later join fail — +# here, and on every path in the control server — with `make reset` the only way +# back. --reset says this command is the whole statement of intent, so nothing +# somebody flipped an hour ago can refuse it. +# +# It also clears the one setting the lab does mean to keep, so that one is named +# explicitly from the file the control server keeps beside the key. A machine +# restarted on its own then comes back the way the switch says it is, rather +# than however --reset left it. +SSH_FLAG=--ssh=false +if [ "$(cat "$STATE_DIR/tailscale-ssh" 2>/dev/null)" = "true" ]; then + SSH_FLAG=--ssh +fi + if [ -s "$STATE_DIR/authkey" ]; then AUTHKEY="$(cat "$STATE_DIR/authkey")" for attempt in 1 2 3 4 5; do if tailscale up \ + --reset \ --login-server="$LAB_LOGIN_SERVER" \ --authkey="$AUTHKEY" \ --hostname="$LAB_HOSTNAME" \ --accept-routes=false \ --accept-dns=false \ + "$SSH_FLAG" \ --timeout=30s; then log "joined the tailnet as $LAB_HOSTNAME" break