A loopback tab that can see its own fleet: the directory, and self-enrolment - #45
Merged
Conversation
A browser on http://127.0.0.1:7717 could not read the fleet directory, and nothing anywhere said so. `readDirectory` fetches `https://<relay>/directory` cross-origin from the loopback tab, the Worker sends no `Access-Control-Allow-Origin` on any route, and the browser discards the response before a byte of it is readable — which `readDirectory` folds into "no machines", its answer to every fault by design. A fully joined machine showed a fleet of one. CSP was not the blocker: LocalCSPFor already names the relay origin in `connect-src`. CORS was, and it is not a policy the daemon can relax from the other end. So `GET /api/fleet/directory`, behind withAuth on the loopback surface: the daemon makes the read on the leg it already holds and hands the relay's bytes back verbatim. Verbatim is the security property — the browser goes on verifying every blob under the fleet public key it pinned, so this is transport and never trust. `Directory.Snapshot` implements it beside `ingestSnapshot` and shares nothing with it: no verification, no counts, no sink, no cached ETag. Preferred over a CORS header on the Worker because it works against relays deployed by an older flue, needs no `flue relay update`, and adds nothing to the relay's public surface. GET-only, so methodPolicy is untouched. A daemon with no relay leg answers 404 and one whose relay refused answers 502, so the browser can tell "no fleet" from "fleet unreachable". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tab on http://127.0.0.1:7717 never ran a pairing ceremony — it did not need one, the session cookie is its credential — so it holds no fleet-signed device certificate and can reach this machine and no other. Every sibling admits a roaming device on the certificate it presents in its handshake, and this browser has none to present. So `POST /api/fleet/enrol`, behind withAuth: the browser sends its device public key, the daemon looks it up (idempotent — a browser calls this on every load), mints a fleet device certificate if it is new, and answers with the device id, the certificate, the fleet public key and this machine's id. It grants no new authority, and the argument was checked in the code before this was built. wire.Spawn passes m.Cmd to registry.Spawn as argv and registry.start runs exec.Command(argv[0], argv[1:]...), so any client that can open /ws on loopback can already `cat relay.json`, read the fleet seed, and mint certificates for the whole fleet without asking this daemon anything. This collapses three steps into one; the blast radius is identical, and the credential that gates it is the credential that gates the shell. HTTP, never the wire protocol, and that is not a style choice. A wire.Enrol would be reachable from every relay-origin device and would let an already-admitted device mint a certificate for a key of its own choosing — admission to one machine becoming manufactured admission to all of them, for keys nobody has proved they hold. relayui.go already codifies "loopback-only surfaces get no wire equivalent"; this follows it. internal/transport/local is unchanged: checkProvenance already refuses a co-resident loopback port (Origin allowlist, Sec-Fetch-Site same-site) and DNS rebinding (Host allowlist), and methodPolicy answers every OPTIONS 405 so no preflight can succeed. Three details worth naming: - The row is labelled "<hostname> — this machine's browser", because it will appear on *other* machines' Devices screens the moment this browser reaches them (AddFromFleetCert) — correct fleet-trust behaviour, and baffling if unlabelled. - Revoking it does not lock the machine out. A loopback connection carries device == "", so addConn files it under no device bucket and disconnectDevice never sees it. Tested end to end over the wire. - A revoked key is refused rather than re-enrolled. FindByKey answers "not found" for a revoked key and Devices.Add does not consult the revocation list, so without an explicit check the next page load would resurrect a device the operator had just cut off. A daemon with no fleet key, or with one and no machine id on the relay, answers 409 and writes nothing rather than half-enrolling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
karngyan
added a commit
that referenced
this pull request
Aug 10, 2026
* feat(web): a loopback tab that joins its own fleet A browser on http://127.0.0.1:7717 listed one machine on a laptop that was fully joined, and nothing on any screen said why. It was missing three records and one read: no fleet key, no device certificate, no machine records — and `GET /directory` on the relay is a cross-origin fetch the Worker answers without `Access-Control-Allow-Origin`, so the answer was discarded before readDirectory saw a byte. Every pairing link points at the relay's address, which is another origin and another storage partition, so no ceremony this tab could run would have fixed any of it. The daemon side landed in #45. This is the browser: - **Enrolment on boot, loopback only.** The fleet posts this browser's device public key at `POST /api/fleet/enrol`, and pins the fleet public key and the certificate the machine signs for it. Once per epoch, never retried: 409 (this machine holds no fleet key) leaves the tab exactly as it was, which is the tab loopback has always been. - **The exemption, written down.** crypto/keys.ts forbids learning a fleet key over the wire, and that rule is untouched. What is different here is that there is no intermediary to be: the "wire" is a socket to 127.0.0.1, routed to the one process that owns the key and served this page. Nor is any authority created — a caller that can open /ws on loopback can spawn a shell and read the fleet *seed* out of relay.json. fleet/enrol.ts carries the argument in full, beside the two checks the browser still makes (the certificate must verify under the key it arrived beside and must name this browser's own device key — consistency, not trust). - **The directory through the daemon.** fleetSources' directoryFetch seam now carries `/api/fleet/directory` on a loopback tab and stays the plain cross-origin read on a relay tab. The bytes are the relay's own, so every blob is still verified under the pinned fleet key. - **Live discovery.** The expansion was one-shot per epoch, so a machine that joined this afternoon never appeared in a tab opened this morning. It re-runs on an interval and on focus, additive only: an empty read, a 502 or a key store that will not open drops nothing. - The fleet gap band's two loopback-facing sentences told the reader to pair from the relay, which that tab cannot act on. Both now name something it can do. Tests: a loopback tab with nothing in its store ends up holding both machines and dialling B's relay slot sealed to B's key with the enrolled certificate; both orders of the enrolment/welcome race; idempotence across reloads; 409 leaves a working one-machine tab; 502 keeps the machine the tab is on; the directory read targets the proxy on loopback and the relay origin elsewhere; discovery adds a late machine and never drops one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(web): say so when a deploy needs the tab reloaded Deploying a relay from the Remote screen left the tab that did it in a state it could not get out of, and nothing said so. Three facts about that page were settled when the daemon served it: - its Content-Security-Policy, built from relay.json at serve time (LocalCSPFor), so a page served without a relay names none and the browser blocks the relay socket and the directory read whatever the app tries; - the relay origin, which a loopback tab learns from the welcome — and relayInfo() reports nothing while the leg is off, with no broadcast when that changes; - this browser's fleet identity, enrolled once per load and answered 409 by a machine that held no fleet key at the time. No amount of polling gets out of the first one, so the deploy's own result now names the reload and offers it. A button rather than an automatic navigation: the steps above it are the reader's only account of what was just done to their Cloudflare account, and every other flow on this card is careful not to navigate away from its own receipt. Setup only. A tab looking at the configured card was served with the relay already in relay.json, so it has nothing to gain from a reload. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(spec): the third delivery of the fleet key, and why loopback earns one The rule in "The second delivery" excludes loopback by name — a session cookie authenticates no key — and that stays exactly as it was. What was missing is that the tab it excludes is also the tab no QR can reach: a pairing link lands on the relay's origin, another storage partition, so the ceremony would admit a browser that is not this one. So the machine's own daemon answers for it, and the section says what that buys and what it costs: enrolment grants nothing a caller who can spawn a shell does not already hold (relay.json holds the seed), it must never become a wire message for the same reason (a relay-origin device cannot read that file), the pin is replaced here because the certificate arrives with the key from the process that minted both, and the directory route is transport rather than trust — it exists because the relay serves no CORS header, and the browser still verifies every blob under the pinned key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(web): pin the read an ordinary loopback load does not make The rebuild after an enrolment is gated on records actually being gained, so the second load and every one after it costs one expansion rather than two. The comment beside the gate now also carries what it deliberately swallows: a development-only double-mount race where two enrolments overlap and the second is told "already there" about records the fleet has not seen. The tab it leaves is one machine short until the next discovery tick, and every predicate that would catch it is wrong in the ordinary case — at the moment the enrolment resumes the expansion is usually still in flight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(web): wait for the enrolment before asserting about it Two of the loopback cases read post.calls after waiting on something that does not depend on the enrolment — the expansion the welcome triggers, which runs whether or not the browser was ever enrolled. Both raced, and both failed about one in three runs on a busy machine. Also the header on fleet.ts: what a loopback tab has to do before any of the merging below means anything, and that the machine set is no longer fixed at page load. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The last two steps of "install flue on Mac A, deploy the relay, pair a phone,
install flue on Mac B, run the join line" work today. What did not work was the
step nobody thought to check: a machine's own loopback browser had no fleet
identity and could not even read the fleet directory. Both machines correctly
joined, both on the relay, both holding the fleet key — and each one's own tab
showed a fleet of one.
Two endpoints, both loopback-only, both behind
withAuth.Why a loopback tab could see nothing, and what CSP had to do with it (nothing)
readDirectoryfetcheshttps://<relay>/directorycross-origin from the tabon
http://127.0.0.1:7717. The Worker sends noAccess-Control-Allow-Originon any route —
grep -rn "Allow-Origin" relay/has zero hits, and/directoryanswers carryJSON_NO_STOREand anETagand nothing else. Sothe browser rejects the promise before a byte of the body is readable, and
readDirectoryfolds every fault intoNOTHING, by design — the failuresurfaces as "no machines" and never as an error.
CSP was not the blocker, and that is worth saying plainly because it is where
the search naturally starts:
LocalCSPForalready puts the relay origin inconnect-src, for exactly this fetch. CORS was the blocker, and it is not apolicy the daemon can relax from the far end.
Every existing test missed it because every
fleetSourcestest injectsdirectoryFetch— the one code path a real browser takes was the one pathnothing exercised.
Why a daemon-side proxy rather than a header on the Worker
GET /api/fleet/directoryhas the daemon make the read on the leg it alreadyholds and hand the relay's answer back verbatim. Preferred over
Access-Control-Allow-Origin: *on the Worker because:need a
flue relay updateon every fleet before loopback tabs came right —and until then the failure is the same silent "no machines";
already authenticated on loopback, rather than to every origin on the
internet.
The Worker header is arguably still worth adding as a complement — it would let
a relay-served bundle use the route without a daemon in the path — but it is
deliberately not in this PR.
Verbatim is the security property, not a shortcut. The browser goes on
verifying every blob under the fleet public key it pinned, so the daemon is
transport and never trust.
Directory.Snapshotsits besideingestSnapshotand shares nothing with it: no verification, no counts, no sink, no cached
ETag (the caller needs a body every time, and the cached tag is a claim about
what this process has applied — a different question from what the browser
has seen).
GET-only, so
methodPolicyis untouched. A daemon with no relay leg answers404; one whose relay refused answers 502, so the browser can tell "no fleet"
from "fleet unreachable".
Self-enrolment, and the no-new-authority argument
POST /api/fleet/enrol: the browser sends its device public key, the daemondoes
FindByKeyfirst (a browser calls this on every load, so it has to beidempotent —
Devices.Adddeliberately is not), else mints andAdds, andanswers with the device id, the device certificate, the fleet public key
and this machine's id.
The argument survived review, and the code makes it stronger than the prose
did. Verified before building:
wire.Spawnpassesm.Cmdtoregistry.Spawnas argv(
internal/daemon/conn.go:514), andregistry.startrunsexec.Command(argv[0], argv[1:]...)(internal/session/registry.go)./wson loopback can alreadycat ~/.config/flue/relay.json, read the fleet seed, and mint certificatesfor the entire fleet without asking this daemon anything.
nothing for the dishonest one. The credential that gates it — the session
token, through
withAuth— is the credential that gates the shell. There isno configuration in which this is reachable and that is not.
HTTP, never the wire protocol
This is the load-bearing decision. A
wire.Enrolmessage would be reachablefrom every relay-origin device, because the wire protocol is exactly what a
relay forwards — and it would let any already-admitted device mint a fleet
certificate for a key of its choosing. That is a real escalation: admission
to one machine becomes manufactured admission to every machine, for keys nobody
has proved they hold. The argument above does not cover it, because a
relay-origin device cannot read
relay.json.internal/daemon/relayui.goalready codifies "loopback-only surfaces get no wire equivalent" for the
Cloudflare token; this follows it, and says so in the doc comment so the next
person reaching for symmetry meets the reason first.
internal/transport/localneeded no change — verified, not modifiedwithAuth'scheckProvenancealready refuses:http://127.0.0.1:3000sends thatOrigin (not on the allowlist) and
Sec-Fetch-Site: same-site(onlysame-originor absent is admitted);127.0.0.1:<port>andlocalhost:<port>and nothing else.And
methodPolicyanswers everyOPTIONS405, so no CORS preflight can eversucceed and no cross-origin JSON POST can be made at all. Not one line of that
package changed.
Three details worth naming
<hostname> — this machine's browser. Statedplainly because it will surprise: after this, machine B's Devices screen
lists A's browser on first contact, via
AddFromFleetCert. That is correctfleet-trust behaviour and exactly the sort of row an operator would otherwise
squint at, so the label says whose browser it is. The certificate carries the
same name, so a sibling writes the same label.
connection authenticates a machine-local session token rather than a device,
so it carries
device == "",addConnfiles it under no device bucket, anddisconnectDevicewalks that bucket and finds nothing. Tested end to end:connect a loopback tab, enrol, revoke over
wire.Revoke, and the tab isstill up and a fresh one still connects.
FindByKeyanswers"not found" for a revoked key and
Devices.Adddoes not consult therevocation list at all, so without an explicit
IsRevokedcheck the nextpage load would resurrect a device the operator had just cut off — a fresh
row on every Devices screen for a key both acceptance paths still refuse. A
revocation permanently outranks a certificate; un-revoking is a new key,
which for a browser means clearing its storage. Mutation-checked: disabling
the guard turns that test's 403 into a 200 and a new registry row.
A daemon with no fleet key, or with one and no machine id on the relay, answers
409 and writes nothing rather than half-enrolling — the same refusal the
pairing ceremony makes, for the same reason: a cert naming the empty machine is
one no reader can attribute.
Endpoint shapes
GET /api/fleet/directory— behindwithAuth, GET only.200— the relay's{"v":1,"entries":[{"key","blob"}]}byte for byte,Content-Type: application/json,Cache-Control: no-store404— no relay leg on this daemon502— relay unreachable or refusing401/403— unauthenticated / cross-originPOST /api/fleet/enrol— behindwithAuth, onmethodPolicy's postablelist.
{"publicKey": "<32 bytes, standard base64>"}(no label — thedaemon names the row)
200—{"deviceId": string, "deviceCert": base64, "fleetPub": base64, "machineId": string},Cache-Control: no-store. The[]bytefields are standard base64 withpadding, matching the pairing answer's
deviceCert.400malformed body or key ·401no session token ·403revoked key orcross-origin ·
409no fleet key, or no machine id ·503no deviceregistry ·
405any method but POSTTest evidence
18 new tests in
internal/daemon/fleet_test.go, 5 ininternal/transport/relay/directory_test.go(on the existingdirFixture/fakeDirectoryharness).Stage 1: 404s with no relay leg; returns the relay's bytes unchanged with one
(asserted on the bytes, including odd whitespace and an unknown field, because
a re-encode would break every signature in the document); 401 unauthenticated
and 403 cross-origin, with the relay not dialled in either case; 405 on POST.
Transport side: the blob survives the round trip, the sink is untouched, the
daemon secret rides along, the read is unconditional, and both a refusal and an
unreachable relay error rather than answering empty.
Stage 2: the certificate verifies under the fleet public key from the same
answer and names the key, the label and the machine id; re-enrolment returns
the same device and the same certificate bytes and leaves one row; the seed
never appears in the answer in any encoding and
fleetPubis 32 bytes; refusedwithout the session token, refused with a bad Origin, refused by GET, refused
for a revoked key, refused on a daemon with no fleet key and on one with no
machine id — each with nothing written to the registry; the row appears with
its label; revoking it leaves the loopback tab connected.
No web sources were touched.
web/src/fleet/fleet.ts,web/src/crypto/keys.ts,internal/daemon/conn.goandinternal/wireare untouched — a later stagewires the browser against these two endpoints.
🤖 Generated with Claude Code