Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,18 @@ docker compose up -d --build --no-deps control
```

Then confirm the server is actually serving your change — `curl` the asset and
grep it — before concluding anything about whether the change worked. The
browser caches these aggressively too, so a hard reload is part of the loop.
grep it — before concluding anything about whether the change worked.

This has already sent one session off to debug a fix that was correct the whole
time.

A hard reload used to be part of that loop as well, because the assets went out
with no `ETag` and no `Cache-Control` and the browser was free to keep whatever
it had. It no longer is: every asset is tagged with a hash of its own bytes and
marked `no-cache`, so an ordinary reload revalidates and picks up the rebuild. A
304 keeps that cheap. If you find yourself reaching for Cmd+Shift+R to see a
change, that is a bug worth reporting rather than a step worth remembering.

## Verify in the lab, not only in the tests

`make check` runs without Docker and finishes in about a second. It is necessary
Expand Down
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ You can run the lab, drive a browser and call the API, which means "I believe
this works" is almost never the best sentence available to you. Run it, and
report what came back.

Two traps that make a correct change look broken, both covered in AGENTS.md:
the UI is `//go:embed`-ed and needs the control container rebuilt, and the
browser caches the assets hard.
One trap that makes a correct change look broken, covered in AGENTS.md: the UI
is `//go:embed`-ed and needs the control container rebuilt. The browser no
longer needs a hard reload on top of that — the assets carry an `ETag` and
revalidate — but the rebuild is still yours to run.

## The lab on this machine is probably in use

Expand Down
47 changes: 47 additions & 0 deletions lab/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,48 @@ key the coordination server holds before and after, so "it re-keyed" is
something the run found rather than something the button claims. It takes about
a minute.

#### Every attack that crosses the public segment measures the route first

`make attack` creates two containers, not one: `evil-box` and `nat-evil`, the
router that carries its packets off its own segment. Stop the router and the
attacker keeps running, keeps its default route, and reaches nothing —
`ping 203.0.113.3` to the lab's own relay is 100% loss while
`docker ps` and `/api/state` both still say `online`.

That state used to score. `nmap` prints `filtered` both for "a firewall dropped
this" and for "nothing carried this", and at rung 4 there is nothing else to
tell them apart, so **Scan from the open internet** answered a stopped router
with `ok`, rung 4 and `ufw default incoming policy: deny` — byte for byte the
verdict a run where the firewall really did the work gets. Measured on Docker
29.7.2 on 2026-09-12, and it was the same for the other six attacks that have
to leave that segment, and for five of the eleven audit checks, where five
failures to reach read as five passes.

So the precondition is no longer "the attacker's container is running". Before
any of those seven scores, one packet goes from `evil-box` to the relay at
`203.0.113.3`, and a run that gets no answer refuses at rung 1, naming the
router rather than a defence:

```
{"ok": false, "rung": 1, "rule": "nat-evil is stopped",
"why": "The attacker's own router is down, so it reaches nothing at all — not
lab-vps, not the coordination server, not the relay. Its route survived
the outage; the gateway at 10.0.66.254 did not. …"}
```

The relay is the target on purpose: it sits on the public segment, holds none of
your firewall rules, and no configuration in this lab changes it. Aiming at
`lab-vps` would ask two questions in one packet. The router is not restarted for
you either — a stopped router is a network condition, like a downed link, and
this lab's job with those is to measure them. The one control that does put it
back is `evil-box`'s own switch in the **Machines** panel, which brings up the
pair and says so, and `/api/state` now carries `routerDown` for any machine
whose NAT box is stopped, which the drawing dims and labels.

**Sit on the wire and capture** and **Replay a captured frame** are the two that
still ask only for `evil-box`, because `nat-evil` carries none of their packets:
both work on the segment `evil-box` already shares with `lab-ubuntu`.

#### The capture has a control experiment in it

"We captured the traffic and could not read it" proves very little on its own:
Expand Down Expand Up @@ -1274,6 +1316,11 @@ that carries everything.
its log, and `make reset && make up` is the clean way through.
- A machine never joins. Try `docker compose logs lab-ubuntu`. The usual cause
is that the lab CA was not there when it booted; `make reset && make up`.
- Every attack refuses at rung 1 with `nat-evil is stopped` or
`nat-evil is not in this stack`. That is the refusal working: the attacker
cannot reach anything, so nothing it does can be scored. `docker start
nat-evil`, or `make attack` if it was never created — starting `evil-box`
on its own with `--no-deps` leaves exactly this.
- Every probe dies at rung 3. Check `docker exec headscale headscale policy get`,
then `docker exec headscale headscale nodes list` and confirm each machine
carries the tag the policy names. See the Headscale wrinkle above.
Expand Down
18 changes: 16 additions & 2 deletions lab/checks/reading.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ import test from "node:test";
import assert from "node:assert/strict";

import {
ev, rung, tone, ladderMark, neverRan, head, scanReading, sniffReading,
tailcatReading
ev, rung, tone, ladderMark, neverRan, head, whyNothingRan, scanReading,
sniffReading, tailcatReading
} from "../control/ui/hud/reading.js";

/* ---------- tone, and the rule that decides it ------------------- */
Expand Down Expand Up @@ -79,6 +79,20 @@ test("neverRan separates the attacker not starting from the defence holding", ()
assert.equal(neverRan({ ok: false, danger: true, rung: 1 }), false);
});

test("a run that never ran says which of the three reasons it was", () => {
/* The attacker not being there was once the only way an attack could
refuse, so the heads asked "is evil-box running?" and were right. A
stopped nat-evil refuses with evil-box running, and a head that still
asked that question would be pointing at the wrong container. */
assert.equal(whyNothingRan({ rung: 1, rule: "evil-box is not in this stack" }),
"evil-box is not in this stack");
assert.equal(whyNothingRan({ rung: 1, rule: "nat-evil is stopped" }),
"nat-evil is stopped");
/* And a refusal that named no rule at all still asks something useful. */
assert.equal(whyNothingRan({ rung: 1 }), "is evil-box running? `make attack`");
assert.equal(whyNothingRan(null), "is evil-box running? `make attack`");
});

test("absent evidence is zero, not missing and not some", () => {
assert.equal(ev({ evidence: { frames: 12 } }, "frames"), 12);
assert.equal(ev({ evidence: { frames: 12 } }, "cleartext"), 0);
Expand Down
46 changes: 37 additions & 9 deletions lab/control/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,29 @@ func (c *Controller) setOnline(ctx context.Context, id string, up bool) Result {
res.Cmds = []string{"docker compose start " + id}
err = c.lab.Start(ctx, id)
res.Why = id + " is running"
// A machine and the router in front of it come up together in compose,
// and this switch used to know only about the machine. Starting
// evil-box while nat-evil is stopped put an attacker on the board that
// could reach nothing — which every attack then scored as a firewall
// holding. So the pair goes back up together, and when the router is
// not there to start, the switch says that instead of leaving the
// reader to find the other half.
m, known := machineByID(id)
switch {
case err != nil || !known || m.Router == "" || c.lab.Running(ctx, m.Router):
case !c.lab.Exists(ctx, m.Router):
res.Why = id + " is running, and " + m.Router + " — the router in front of it —" +
" does not exist, so it reaches nothing off its own segment. That is what " +
"`--no-deps` leaves behind; `make attack` brings up the pair."
default:
if rerr := c.lab.Start(ctx, m.Router); rerr == nil {
res.Cmds = append(res.Cmds, "docker compose start "+m.Router+
" # its router, or it reaches nothing")
res.Why = id + " is running, and so is " + m.Router + " — the router in " +
"front of it, which was stopped. Without that one the machine is up " +
"and reaching nothing, and nothing measured from there is worth a score."
}
}
} else {
res.Cmds = []string{"docker compose stop " + id}
err = c.lab.Stop(ctx, id)
Expand Down Expand Up @@ -387,6 +410,15 @@ func (c *Controller) evilJoin(ctx context.Context, join bool) Result {
"a handshake with it. Turn lock off and try the same thing again."
return res
}
// Lock first, because that refusal is a fact about the lab whether or not
// anything could have been reached. After it, a join is a packet to the
// coordination server on the public segment — and without a route there
// `tailscale up` spends its thirty seconds and reports a timeout, which is
// true and no help at all beside the one fact that explains it.
if r := c.needRouteOut(ctx); r != nil {
r.Rule = "it cannot reach the coordination server: " + r.Rule
return *r
}
if c.untrustedKey == "" {
res.Why = "no spare key was created at boot — try `make reset`"
return res
Expand Down Expand Up @@ -458,16 +490,12 @@ func (c *Controller) setOnPath(ctx context.Context, on bool) Result {
return res
}

// gatewayFor is the address the machine's own router answers on. It reads the
// catalog rather than repeating it, because the same three addresses are now
// also what names the fault when one of those routers is down.
func (c *Controller) gatewayFor(id string) string {
switch id {
case "lab-ubuntu":
return "10.0.13.254"
case "lab-roam":
return "10.0.27.254"
case "evil-box":
return "10.0.66.254"
}
return ""
m, _ := machineByID(id)
return m.Gateway
}

// ---------------------------------------------------------------------------
Expand Down
89 changes: 89 additions & 0 deletions lab/control/assets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2026 Hashem Aldhaheri

package main

import (
"crypto/sha256"
"encoding/hex"
"io/fs"
"net/http"
"path"
"strings"
)

// The UI is //go:embed-ed, so the server knows the exact bytes of every asset
// at build time and can say so. Without that, style.css and app.js went out
// with no Cache-Control, no ETag and no Last-Modified — embed.FS reports a zero
// modtime, so net/http had nothing to put there — and a browser handed a
// response with no freshness information at all is free to invent one. The loop
// this lab is built around is edit, `docker compose up -d --build --no-deps
// control`, look at the page; a rebuilt server that does not change the page
// reads as a broken fix, and that cost a session on #68.
//
// So every asset carries an ETag over its own bytes and Cache-Control:
// no-cache, which does not mean "do not store" — it means store it and ask
// before using it. The browser revalidates on every load and gets a 304 with no
// body back when nothing moved. One round trip against a server on loopback,
// and the page can never be a new app.js against yesterday's style.css.
//
// The hash is computed once at startup rather than per request: the bytes are
// in the binary and cannot change while it runs.

// staticUI serves fsys with a per-file ETag and Cache-Control: no-cache.
//
// It sets the header and then hands off to http.FileServer, which is not a
// trick: http.ServeContent reads the ETag already on the ResponseWriter when it
// evaluates If-None-Match, so the 304 handling, range requests and content
// sniffing all stay where they were.
func staticUI(fsys fs.FS) (http.Handler, error) {
tags, err := assetETags(fsys)
if err != nil {
return nil, err
}
files := http.FileServer(http.FS(fsys))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if tag, ok := tags[assetName(r.URL.Path)]; ok {
w.Header().Set("ETag", tag)
w.Header().Set("Cache-Control", "no-cache")
}
files.ServeHTTP(w, r)
}), nil
}

// assetETags hashes every file in fsys, keyed by its path within fsys.
func assetETags(fsys fs.FS) (map[string]string, error) {
tags := map[string]string{}
err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
b, err := fs.ReadFile(fsys, p)
if err != nil {
return err
}
sum := sha256.Sum256(b)
// Half of SHA-256 is far more than enough to tell two versions of one
// stylesheet apart, and a shorter header is easier to read in curl -I.
tags[p] = `"` + hex.EncodeToString(sum[:16]) + `"`
return nil
})
if err != nil {
return nil, err
}
return tags, nil
}

// assetName maps a request path to the name of the embedded file
// http.FileServer will serve for it, so the two agree on what is being tagged.
// A directory gets its index.html, which is how / becomes index.html.
func assetName(p string) string {
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
// Before Clean, which strips the trailing slash that marks a directory.
if strings.HasSuffix(p, "/") {
p += "index.html"
}
return strings.TrimPrefix(path.Clean(p), "/")
}
Loading
Loading