Skip to content

A loopback tab that can see its own fleet: the directory, and self-enrolment - #45

Merged
karngyan merged 2 commits into
mainfrom
feat/loopback-fleet-identity
Aug 10, 2026
Merged

A loopback tab that can see its own fleet: the directory, and self-enrolment#45
karngyan merged 2 commits into
mainfrom
feat/loopback-fleet-identity

Conversation

@karngyan

Copy link
Copy Markdown
Contributor

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)

readDirectory fetches https://<relay>/directory cross-origin from the tab
on http://127.0.0.1:7717. The Worker sends no Access-Control-Allow-Origin
on any route
grep -rn "Allow-Origin" relay/ has zero hits, and
/directory answers carry JSON_NO_STORE and an ETag and nothing else. So
the browser rejects the promise before a byte of the body is readable, and
readDirectory folds every fault into NOTHING, by design — the failure
surfaces 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: LocalCSPFor already puts the relay origin in
connect-src, for exactly this fetch. CORS was the blocker, and it is not a
policy the daemon can relax from the far end.

Every existing test missed it because every fleetSources test injects
directoryFetch — the one code path a real browser takes was the one path
nothing exercised.

Why a daemon-side proxy rather than a header on the Worker

GET /api/fleet/directory has the daemon make the read on the leg it already
holds and hand the relay's answer back verbatim. Preferred over
Access-Control-Allow-Origin: * on the Worker because:

  • it works against relays deployed by an older flue, where the header would
    need a flue relay update on every fleet before loopback tabs came right —
    and until then the failure is the same silent "no machines";
  • it adds nothing to the relay's public surface: the bytes go to a caller
    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.Snapshot sits beside ingestSnapshot
and 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 methodPolicy is untouched. A daemon with no relay leg answers
404; 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 daemon
does FindByKey first (a browser calls this on every load, so it has to be
idempotent — Devices.Add deliberately is not), else mints and Adds, and
answers 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.Spawn passes m.Cmd to registry.Spawn as argv
    (internal/daemon/conn.go:514), and registry.start runs
    exec.Command(argv[0], argv[1:]...) (internal/session/registry.go).
  • So any client that can open /ws on loopback can already
    cat ~/.config/flue/relay.json, read the fleet seed, and mint certificates
    for the entire fleet without asking this daemon anything.
  • This endpoint collapses three steps into one for the honest case and changes
    nothing for the dishonest one. The credential that gates it — the session
    token, through withAuth — is the credential that gates the shell. There is
    no configuration in which this is reachable and that is not.

HTTP, never the wire protocol

This is the load-bearing decision. A wire.Enrol message would be reachable
from 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.go
already 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/local needed no change — verified, not modified

withAuth's checkProvenance already refuses:

  • a co-resident loopback port — a page on http://127.0.0.1:3000 sends that
    Origin (not on the allowlist) and Sec-Fetch-Site: same-site (only
    same-origin or absent is admitted);
  • DNS rebinding — the Host allowlist is 127.0.0.1:<port> and
    localhost:<port> and nothing else.

And methodPolicy answers every OPTIONS 405, so no CORS preflight can ever
succeed and no cross-origin JSON POST can be made at all. Not one line of that
package changed.

Three details worth naming

  • The row is labelled <hostname> — this machine's browser. Stated
    plainly because it will surprise: after this, machine B's Devices screen
    lists A's browser on first contact, via AddFromFleetCert. That is correct
    fleet-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.
  • Revoking it does not lock the machine out of its own UI. A loopback
    connection authenticates a machine-local session token rather than a device,
    so it carries device == "", addConn files it under no device bucket, and
    disconnectDevice walks that bucket and finds nothing. Tested end to end:
    connect a loopback tab, enrol, revoke over wire.Revoke, and the tab is
    still up and a fresh one still connects.
  • 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 at all, so without an explicit IsRevoked check the next
    page 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 — behind withAuth, GET only.

  • 200 — the relay's {"v":1,"entries":[{"key","blob"}]} byte for byte,
    Content-Type: application/json, Cache-Control: no-store
  • 404 — no relay leg on this daemon
  • 502 — relay unreachable or refusing
  • 401 / 403 — unauthenticated / cross-origin

POST /api/fleet/enrol — behind withAuth, on methodPolicy's postable
list.

  • request: {"publicKey": "<32 bytes, standard base64>"} (no label — the
    daemon names the row)
  • 200
    {"deviceId": string, "deviceCert": base64, "fleetPub": base64, "machineId": string},
    Cache-Control: no-store. The []byte fields are standard base64 with
    padding, matching the pairing answer's deviceCert.
  • 400 malformed body or key · 401 no session token · 403 revoked key or
    cross-origin · 409 no fleet key, or no machine id · 503 no device
    registry · 405 any method but POST

Test evidence

18 new tests in internal/daemon/fleet_test.go, 5 in
internal/transport/relay/directory_test.go (on the existing dirFixture /
fakeDirectory harness).

make web relay                            ok
go build ./... && go vet ./...            clean
gofmt -l ./cmd ./internal                 only the two files already unformatted on main
go test ./...                             ok (all packages)
go test -race ./internal/crypto/... ./internal/transport/... ./internal/fleet/...   ok
go test -race ./internal/daemon/          ok

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 fleetPub is 32 bytes; refused
without 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.go and internal/wire are untouched — a later stage
wires the browser against these two endpoints.

🤖 Generated with Claude Code

karngyan and others added 2 commits August 10, 2026 22:35
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
karngyan merged commit 8fc32a6 into main Aug 10, 2026
1 check passed
@karngyan
karngyan deleted the feat/loopback-fleet-identity branch August 10, 2026 17:19
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant