From b0d15b3a534f532af2be0df08b0782b7ceb2e0cf Mon Sep 17 00:00:00 2001 From: Karn Date: Mon, 10 Aug 2026 23:12:30 +0530 Subject: [PATCH 1/5] feat(web): a loopback tab that joins its own fleet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/crypto/keys.ts | 32 ++- web/src/fleet/enrol.test.ts | 211 ++++++++++++++++ web/src/fleet/enrol.ts | 251 +++++++++++++++++++ web/src/fleet/fleet.test.ts | 406 ++++++++++++++++++++++++++++++- web/src/fleet/fleet.ts | 178 +++++++++++++- web/src/fleet/provider.test.tsx | 45 +++- web/src/fleet/provider.tsx | 59 +++-- web/src/main.tsx | 15 +- web/src/router.tsx | 15 +- web/src/routes/sessions.test.tsx | 11 +- web/src/routes/sessions.tsx | 43 +++- web/src/testing/fleet.ts | 45 ++++ 12 files changed, 1253 insertions(+), 58 deletions(-) create mode 100644 web/src/fleet/enrol.test.ts create mode 100644 web/src/fleet/enrol.ts diff --git a/web/src/crypto/keys.ts b/web/src/crypto/keys.ts index ad03cb8..ce1a5fb 100644 --- a/web/src/crypto/keys.ts +++ b/web/src/crypto/keys.ts @@ -247,16 +247,22 @@ export async function loadPinnedDaemonKeyFor( * overwrite would be stranded on a key nothing signs under any more, with * clearing site data as the only way back. * - * Which puts the weight on the caller, and the two callers carry it - * differently. The ceremony writes what the QR said, unconditionally. The other - * — fleet/fleet.ts's adoptFleetKey — writes only into an empty record, and only - * a key that reached this browser over a Noise session keyed to a daemon static - * key it pinned at a ceremony of its own. Neither is trust-on-first-use: the - * first learned the key out of band, and the second learned it from a party it - * had already authenticated out of band. What would be is a key taken off a - * connection to a peer this browser never pinned — whoever supplied it could - * then mint a machine certificate for every machine this browser will ever - * dial — and there is no path here that does that. + * Which puts the weight on the caller, and the three callers carry it + * differently. The ceremony writes what the QR said, unconditionally. + * fleet/fleet.ts's adoptFleetKey writes only into an empty record, and only a + * key that reached this browser over a Noise session keyed to a daemon static + * key it pinned at a ceremony of its own. fleet/enrol.ts writes what this + * machine's own daemon answered on loopback, overwriting if it differs, because + * there the answer comes from the process that owns the key over a socket with + * no room in it for anybody else — and because a loopback tab has no ceremony + * to be sent back to. + * + * None of the three is trust-on-first-use: the first learned the key out of + * band, the second from a party it had already authenticated out of band, the + * third from the machine the page itself came from. What would be is a key + * taken off a connection to a peer this browser never pinned — whoever supplied + * it could then mint a machine certificate for every machine this browser will + * ever dial — and there is no path here that does that. */ export async function savePinnedFleetKey( publicKey: Uint8Array, @@ -310,8 +316,10 @@ export async function loadPinnedFleetKey( * is worth knowing before relying on it: a loopback connection authenticates a * machine-local session token rather than a device key, so the daemon does not * know whose certificate to send and sends none (internal/daemon/server.go, - * fleetCertFor). A browser paired only over loopback holds what the ceremony - * wrote here and has no second source for it. + * fleetCertFor). What a loopback tab has instead is the third writer, + * fleet/enrol.ts: it names its device key over `POST /api/fleet/enrol` and the + * daemon signs one for it, which is how a browser that never ran a ceremony + * comes to hold a certificate at all. * * It is public data either way: a certificate is a signed statement about a * public key, and holding one grants nothing without the private half of the diff --git a/web/src/fleet/enrol.test.ts b/web/src/fleet/enrol.test.ts new file mode 100644 index 0000000..f2a262b --- /dev/null +++ b/web/src/fleet/enrol.test.ts @@ -0,0 +1,211 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { IDBFactory } from 'fake-indexeddb' +import { x25519 } from '@noble/curves/ed25519.js' + +import { + loadOrCreateDeviceKey, + loadPinnedDeviceCert, + loadPinnedFleetKey, + savePinnedDeviceCert, + savePinnedFleetKey, +} from '@/crypto/keys' +import { + base64, + deviceCert, + enrolAnswer, + enrolFetch, + FLEET_PUB, + machineCert, + OTHER_PUB, + OTHER_SEED, +} from '@/testing/fleet' +import { ENROL_PATH, enrolThisBrowser, FLEET_DIRECTORY_PATH, readDirectoryViaDaemon } from './enrol' + +/** The machine this daemon holds on the relay, as its answer names it. */ +const MACHINE = 'blue-mesa-1a2b' + +/** Some other machine's Noise key, for the certificate that is not this + * browser's to hold. */ +const LOFT_PUB = x25519.getPublicKey(new Uint8Array(32).fill(0x5c)) + +/** The daemon's answer for `cert`, under this fleet's key unless a case is + * about one that is not. */ +const answerWith = (cert: Uint8Array, fleetPub: Uint8Array = FLEET_PUB) => + enrolAnswer(cert, fleetPub, MACHINE) + +describe('enrolThisBrowser', () => { + beforeEach(() => { + vi.stubGlobal('indexedDB', new IDBFactory()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('posts this browser’s own key and keeps what the machine signed for it', async () => { + const key = await loadOrCreateDeviceKey() + const cert = deviceCert(key.publicKey, 'mesa — this machine’s browser', MACHINE) + const post = enrolFetch(answerWith(cert)) + + expect(await enrolThisBrowser(post)).toBe(true) + + // The two records a browser needs to be on a fleet rather than on one + // machine, from the process that owns both. + expect(await loadPinnedFleetKey()).toEqual(FLEET_PUB) + expect(await loadPinnedDeviceCert()).toEqual(cert) + expect(post.calls).toEqual([ + { path: ENROL_PATH, body: JSON.stringify({ publicKey: base64(key.publicKey) }) }, + ]) + }) + + it('is idempotent across reloads: the second answer changes nothing', async () => { + // The browser has no way to know whether this daemon has seen it before, so + // it asks on every load; the daemon answers a known key with the same id + // and the same certificate bytes. What must not happen is a browser that + // reports a gain — and re-reads the directory — once per page load. + const key = await loadOrCreateDeviceKey() + const cert = deviceCert(key.publicKey, 'mesa', MACHINE) + const post = enrolFetch(answerWith(cert)) + + expect(await enrolThisBrowser(post)).toBe(true) + expect(await enrolThisBrowser(post)).toBe(false) + expect(await enrolThisBrowser(post)).toBe(false) + + expect(await loadPinnedFleetKey()).toEqual(FLEET_PUB) + expect(await loadPinnedDeviceCert()).toEqual(cert) + // Asked every time, which is the endpoint's own contract: idempotent by + // lookup, so asking is how a browser finds out. + expect(post.calls).toHaveLength(3) + }) + + it('keeps nothing when this machine holds no fleet key', async () => { + // 409, the honest refusal from a machine that has not joined a relay. The + // tab it leaves behind is the tab loopback has always been. + const post = enrolFetch('this machine holds no fleet key', { ok: false, status: 409 }) + + expect(await enrolThisBrowser(post)).toBe(false) + + expect(await loadPinnedFleetKey()).toBeNull() + expect(await loadPinnedDeviceCert()).toBeNull() + // Once. A tab that polled a 409 would be a request every few seconds for as + // long as it stayed open, and the cure is a change on the machine. + expect(post.calls).toHaveLength(1) + }) + + it('keeps nothing for a revoked key, an old daemon, or a fetch that failed', async () => { + for (const status of [403, 404, 401, 503]) { + expect(await enrolThisBrowser(enrolFetch('no', { ok: false, status }))).toBe(false) + } + const thrown = () => Promise.reject(new Error('network')) + expect(await enrolThisBrowser(thrown)).toBe(false) + expect(await loadPinnedFleetKey()).toBeNull() + expect(await loadPinnedDeviceCert()).toBeNull() + }) + + it('refuses a certificate that does not verify under the key beside it', async () => { + // Consistency, not trust — the trust here is the origin. What the check + // buys is that a browser never stores a blob it could only be refused for + // presenting to a sibling machine. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const key = await loadOrCreateDeviceKey() + const cert = deviceCert(key.publicKey, 'mesa', MACHINE, 1_754_700_000, OTHER_SEED) + + expect(await enrolThisBrowser(enrolFetch(answerWith(cert)))).toBe(false) + + expect(await loadPinnedFleetKey()).toBeNull() + expect(await loadPinnedDeviceCert()).toBeNull() + expect(warn).toHaveBeenCalledTimes(1) + }) + + it('refuses a certificate for another device, and one that is not a device certificate', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const someoneElse = x25519.getPublicKey(new Uint8Array(32).fill(0x77)) + + expect(await enrolThisBrowser(enrolFetch(answerWith(deviceCert(someoneElse))))).toBe(false) + expect(await enrolThisBrowser(enrolFetch(answerWith(machineCert('loft-9f9f', 'Loft', LOFT_PUB))))).toBe( + false, + ) + + expect(await loadPinnedFleetKey()).toBeNull() + expect(await loadPinnedDeviceCert()).toBeNull() + }) + + it('refuses a fleet key that is not 32 bytes, and an answer that is not the shape', async () => { + const key = await loadOrCreateDeviceKey() + const cert = deviceCert(key.publicKey, 'mesa', MACHINE) + + expect(await enrolThisBrowser(enrolFetch(answerWith(cert, FLEET_PUB.slice(0, 31))))).toBe(false) + expect(await enrolThisBrowser(enrolFetch({ deviceId: 'x' }))).toBe(false) + expect(await enrolThisBrowser(enrolFetch(''))).toBe(false) + + expect(await loadPinnedFleetKey()).toBeNull() + expect(await loadPinnedDeviceCert()).toBeNull() + }) + + it('takes this machine’s key over a stale pin, and the certificate with it', async () => { + // `flue relay setup` run again mints a fresh fleet key. adoptFleetKey + // refuses that overwrite because a welcome brings no certificate to go with + // it and a ceremony is the way out; on loopback both halves arrive together + // from the machine that minted them, and there is no ceremony to go back to + // — a pairing link points at the relay's address, another origin and + // another storage partition. + const key = await loadOrCreateDeviceKey() + await savePinnedFleetKey(OTHER_PUB) + await savePinnedDeviceCert(deviceCert(key.publicKey, 'old', MACHINE, 1_700_000_000, OTHER_SEED)) + const cert = deviceCert(key.publicKey, 'mesa', MACHINE) + + expect(await enrolThisBrowser(enrolFetch(answerWith(cert)))).toBe(true) + + expect(await loadPinnedFleetKey()).toEqual(FLEET_PUB) + expect(await loadPinnedDeviceCert()).toEqual(cert) + }) + + it('reports the first certificate under a key it already held', async () => { + // A browser that pinned the fleet key some other way and holds no + // certificate has every machine on the fleet to gain, so this is a gain. + await savePinnedFleetKey(FLEET_PUB) + const key = await loadOrCreateDeviceKey() + const cert = deviceCert(key.publicKey, 'mesa', MACHINE) + + expect(await enrolThisBrowser(enrolFetch(answerWith(cert)))).toBe(true) + expect(await loadPinnedDeviceCert()).toEqual(cert) + }) + + it('keeps a replacement certificate without calling it a gain', async () => { + // Every machine that ever certified this device signed a blob of its own, + // all equally admitted everywhere and differing only in name and pairedOn. + // So a new one is written down — the machine that just answered is as good + // a source as any — and changes no machine's reachability. + await savePinnedFleetKey(FLEET_PUB) + const key = await loadOrCreateDeviceKey() + await savePinnedDeviceCert(deviceCert(key.publicKey, 'mesa', 'attic-pi')) + const fresh = deviceCert(key.publicKey, 'mesa', MACHINE, 1_759_999_999) + + expect(await enrolThisBrowser(enrolFetch(answerWith(fresh)))).toBe(false) + expect(await loadPinnedDeviceCert()).toEqual(fresh) + }) +}) + +describe('readDirectoryViaDaemon', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('reads the daemon’s own path, uncached, with the session cookie', async () => { + // And ignores the relay URL it is handed: that origin is precisely the one + // this tab cannot fetch from — the Worker sends no CORS header — which is + // the whole reason the seam exists. + const calls: Array<[string, RequestInit | undefined]> = [] + vi.stubGlobal('fetch', (url: string, init?: RequestInit) => { + calls.push([url, init]) + return Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('{}') }) + }) + + await readDirectoryViaDaemon('https://relay.example/directory') + + expect(calls).toEqual([ + [FLEET_DIRECTORY_PATH, { cache: 'no-store', credentials: 'same-origin' }], + ]) + }) +}) diff --git a/web/src/fleet/enrol.ts b/web/src/fleet/enrol.ts new file mode 100644 index 0000000..8b584b2 --- /dev/null +++ b/web/src/fleet/enrol.ts @@ -0,0 +1,251 @@ +/* + * How a tab on the daemon's own origin becomes a device of the fleet, and how + * it reads the fleet directory from the one party that can reach it. + * + * A browser that paired over the relay holds four things: its own device key, a + * fleet-signed certificate naming that key, the fleet's public key to check + * every other certificate under, and a `GET /directory` on the origin that + * served it. A tab the user opened on http://127.0.0.1:7717 holds the first and + * none of the rest. It never ran a ceremony because it never needed one — the + * session cookie is its credential, and it was only ever talking to the machine + * it is on. + * + * That cost nothing while a browser reached one machine. It is the whole gap + * now that the sessions screen is the fleet's: a loopback tab listed one machine + * on a laptop that was fully joined, with nothing on any screen saying why. + * Three records missing, and a fourth obstacle that no record would have fixed — + * the relay's `GET /directory` is a cross-origin fetch from 127.0.0.1 and the + * Worker sends no `Access-Control-Allow-Origin`, so the answer is discarded + * before `readDirectory` sees a byte of it. + * + * The daemon that served the page answers both halves on its loopback surface + * (internal/daemon/fleet.go): `POST /api/fleet/enrol` mints this browser a + * device certificate and hands over the fleet public key, and + * `GET /api/fleet/directory` fetches the relay's directory and returns those + * bytes unchanged. Everything downstream is exactly what it was — every blob + * still verified under the pinned fleet key, one source built per machine that + * verifies — because the daemon added transport here and not trust. + * + * # Why a fleet key may be learned over this connection + * + * crypto/keys.ts is emphatic that it may not be learned over *a* connection, + * and that rule is untouched. A key taken off an arbitrary connection is + * trust-on-first-use one level up: whoever supplied it can then sign a machine + * certificate for every machine this browser will ever dial. So the two writers + * that existed both had the user behind them — the QR the user carried across + * from a screen they control, and (fleet.ts, adoptFleetKey) a Noise session + * keyed to a daemon key pinned at exactly such a ceremony. + * + * The narrow thing that is different here is that there is no intermediary to + * be. The "wire" is a socket to 127.0.0.1, which the operating system routes to + * one process on this computer and to nothing else: the process that owns the + * fleet key, wrote it to relay.json, and served this page. Nothing sits between + * a tab and its own daemon — no relay, no hop, no name to resolve — so there is + * no party for the argument against wire-learned keys to be about. What answers + * is not "a peer", it is this machine. + * + * Nor is any authority created. The tab calling this already holds strictly + * more: a client that can open /ws on loopback can spawn a shell, and a shell + * can read `~/.config/flue/relay.json`, which holds the fleet *seed* — the + * signing half. Anyone able to reach this endpoint could already mint whatever + * certificate they liked, for the whole fleet, without asking. The endpoint's + * own comment (internal/daemon/fleet.go, EnrolPath) sets that argument out in + * full, including why it is HTTP-on-loopback and must never become a wire + * message: over the wire it *would* be an escalation, because a relay-origin + * device cannot read relay.json. + * + * Which leaves the two things this module does check, and they are consistency + * rather than trust: the certificate has to verify under the fleet key it + * arrived beside, and it has to name this browser's own device key. Both fail + * closed. Trust here comes from the origin; the checks are what stop a browser + * storing a certificate it could only ever be refused for presenting. + * + * None of this reaches a relay-served tab. Nothing on a relay origin imports + * this module, the endpoints do not exist there, and `adoptFleetKey`'s rule + * about welcomes is exactly as strict as it was. + */ +import { sameKey, verifyCert } from '@/crypto/cert' +import { + KEY_BYTES, + loadOrCreateDeviceKey, + loadPinnedDeviceCert, + loadPinnedFleetKey, + savePinnedDeviceCert, + savePinnedFleetKey, +} from '@/crypto/keys' +import type { DirectoryAnswer, DirectoryFetch } from '@/relay/directory' + +/** `POST /api/fleet/enrol` — daemon.EnrolPath. Loopback only, and behind the + * session token like every other /api route. */ +export const ENROL_PATH = '/api/fleet/enrol' + +/** `GET /api/fleet/directory` — daemon.FleetDirectoryPath. The relay's own + * answer, byte for byte, from a party that is allowed to ask for it. */ +export const FLEET_DIRECTORY_PATH = '/api/fleet/directory' + +/** The slice of `fetch` this module uses, so a test can answer without a + * Response implementation jsdom does not have. The same three members + * DirectoryAnswer names, for the same reason. */ +export type EnrolPost = (path: string, body: string) => Promise + +/** The enrolment answer, as daemon.enrolAnswer writes it. `deviceId` and + * `machineId` are read past deliberately: the fleet learns which slot this + * machine holds from the welcome that names it, and a second source for one + * fact is a second thing that can disagree. */ +interface EnrolBody { + deviceCert?: unknown + fleetPub?: unknown +} + +/** + * Ask this machine's daemon to enrol this browser, and keep what it hands back. + * + * Called once per fleet epoch on a loopback tab, before anything is expanded — + * the browser has no way to know whether this daemon has seen it before, and + * asking is cheaper than remembering. The endpoint is idempotent by lookup: a + * key it already holds is answered with the same device id and the same + * certificate bytes, so the second call and the two-hundredth write nothing. + * + * **Every failure is the same answer: false, quietly.** A daemon with no fleet + * key answers 409 (the machine has not joined a relay, or joined one before + * fleet keys existed), a revoked key gets 403, an old daemon 404, a fetch that + * cannot leave the tab throws. None of them is retried here — one attempt per + * epoch, because the cure for all of them is a change on the machine, and a tab + * that polled an endpoint answering 409 would be a request every few seconds + * for as long as it stayed open. A tab in that state is exactly the tab this + * browser has always been on loopback: one machine, working. + * + * Returns whether this browser gained something that changes *which machines it + * can reach* — a fleet key it did not hold, a first certificate, or a key that + * replaced a stale one. That, and only that, is worth re-reading the directory + * for; see FleetClient.connect. The ordinary second load reports false. + */ +export async function enrolThisBrowser(post: EnrolPost = browserPost): Promise { + try { + const key = await loadOrCreateDeviceKey() + const res = await post(ENROL_PATH, JSON.stringify({ publicKey: encodeBase64(key.publicKey) })) + if (!res.ok) return false + const body = JSON.parse(await res.text()) as EnrolBody | null + + const fleetPub = decodeBase64(body?.fleetPub) + // The same width every reader in crypto/keys.ts enforces. A record of any + // other length is one that module could never have written, and a verifier + // handed it refuses every certificate — a browser listing no machines + // rather than the wrong ones. + if (fleetPub === null || fleetPub.length !== KEY_BYTES) return false + const blob = decodeBase64(body?.deviceCert) + if (blob === null) return false + + // Checked against the key it arrived beside, which proves nothing about the + // daemon and is not meant to: see the exemption above. What it does prove + // is that the two records are usable together and that the certificate is + // this browser's own — a blob failing either is one that would be presented + // to a sibling machine and refused, so it is better never stored. + const cert = verifyCert(fleetPub, blob) + if (cert === null || cert.kind !== 'device' || !sameKey(cert.device, key.publicKey)) { + console.warn('flue: this machine offered its own browser a certificate that does not check out; ignoring it') + return false + } + + // Read before either write, because "was there one before this" is the + // answer the caller acts on and the write destroys it. + const heldKey = await loadPinnedFleetKey() + const heldCert = await loadPinnedDeviceCert() + const sameFleet = heldKey !== null && sameKey(heldKey, fleetPub) + const sameCert = heldCert !== null && sameBytes(heldCert, blob) + if (sameFleet && sameCert) return false + + /* + * **The daemon's key wins here, where a welcome's would not.** + * adoptFleetKey refuses to overwrite a pin and says why: two fleet keys + * differ only if the fleet was set up again, and this browser's certificate + * is signed by the old one, so adopting the new key alone would leave it + * listing machines it cannot present anything to — with the ceremony as the + * way out. + * + * Neither half of that holds on loopback. The certificate arrives *with* + * the key, from the process that minted both, so the pair is coherent the + * moment it lands rather than after somebody scans something. And there is + * no ceremony to fall back on: a pairing link points at the relay's + * address, which is a different origin and a different storage partition, + * so a loopback tab that refused the overwrite would be stranded on a key + * nothing signs under any more with no way back but clearing site data. + * + * The key first and the certificate second, the ordering adoptFleetCert + * relies on for the same reason: the certificate is verified under the + * pinned key everywhere else it is read. + */ + if (!sameFleet) await savePinnedFleetKey(fleetPub) + if (!sameCert) await savePinnedDeviceCert(blob) + // A replacement certificate under the fleet key this browser already had + // changes no machine's reachability — every machine admits any certificate + // this fleet signed — so it is written down and reported as nothing gained. + return !sameFleet || heldCert === null + } catch { + // A key store that will not open, a daemon that answered something that is + // not JSON, a fetch the browser refused. All of them mean this tab is the + // tab it was before: one machine, and every screen on it working. + return false + } +} + +/** + * Read the fleet directory through the daemon that served this page. + * + * The URL `readDirectory` builds is deliberately ignored. That argument names + * the relay origin, which is precisely the origin this tab cannot fetch from — + * the whole reason this seam exists — and one daemon reads one relay, so there + * is nothing for the caller to choose between. What comes back is the relay's + * own answer unchanged (daemon.handleFleetDirectory forwards the bytes), so + * every blob in it is verified under the pinned fleet key exactly as it is when + * a relay tab reads the same document directly. + * + * `no-store` for the reason the direct read gives: a directory served from a + * cache is a revocation served late. `same-origin` credentials because this + * route, unlike the relay's, is behind the session token. + * + * The honest failures arrive as a status and cost nothing but machines this + * browser does not learn of — 404 from a daemon reading no directory at all, + * 502 from one whose relay would not answer — because `readDirectory` treats + * every non-ok answer as "nothing learned" and the sources built from pairing + * records, and the loopback machine itself, stand either way. + */ +export const readDirectoryViaDaemon: DirectoryFetch = () => + fetch(FLEET_DIRECTORY_PATH, { cache: 'no-store', credentials: 'same-origin' }) + +/** The real POST: same-origin credentials, because withAuth wants the session + * cookie, and no cache anywhere near a credential. */ +function browserPost(path: string, body: string): Promise { + return fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + cache: 'no-store', + body, + }) +} + +/** Standard base64 with padding — how Go's encoding/json reads the `[]byte` + * this field decodes into, and what daemon.handleEnrol expects. */ +function encodeBase64(bytes: Uint8Array): string { + let binary = '' + for (const b of bytes) binary += String.fromCharCode(b) + return btoa(binary) +} + +/** The same alphabet on the way back. Null for anything that is not a string + * of it, which is every shape a daemon speaking another protocol could send. */ +function decodeBase64(value: unknown): Uint8Array | null { + if (typeof value !== 'string' || value === '') return null + try { + return Uint8Array.from(atob(value), (c) => c.charCodeAt(0)) + } catch { + return null + } +} + +/** Whether two blobs are the same bytes. Not constant-time, and does not need + * to be: both sides are public artifacts this browser already holds. */ +function sameBytes(a: Uint8Array, b: Uint8Array): boolean { + return a.length === b.length && a.every((byte, at) => byte === b[at]) +} diff --git a/web/src/fleet/fleet.test.ts b/web/src/fleet/fleet.test.ts index a175e81..dbd49f1 100644 --- a/web/src/fleet/fleet.test.ts +++ b/web/src/fleet/fleet.test.ts @@ -13,12 +13,14 @@ import { savePinnedDeviceCert, savePinnedFleetKey, } from '@/crypto/keys' -import { saveMachine } from '@/relay/machines' +import { listMachines, saveMachine } from '@/relay/machines' import type { RawSocket } from '@/relay/socket' import { base64, deviceCert, directoryFetch, + enrolAnswer, + enrolFetch, FLEET_PUB, machineCert, OTHER_PUB, @@ -26,6 +28,7 @@ import { revocation, } from '@/testing/fleet' import { responderHandshake } from '@/testing/noise-daemon' +import { enrolThisBrowser } from './enrol' import { adoptFleetCert, adoptFleetKey, @@ -1460,3 +1463,404 @@ describe('a browser paired before its machine had a fleet key', () => { h.fleet.close() }) }) + +// --------------------------------------------------------------------------- +// The loopback tab, end to end. This is the case every piece above exists for: +// a browser that never ran a ceremony, on the machine's own address, ending up +// with the whole fleet — no QR, no link, nothing carried across a screen. +// --------------------------------------------------------------------------- + +/** The slot this machine holds on the relay: the twin, and what the daemon's + * own welcome and enrolment answer both name. */ +const MESA_SLOT = 'blue-mesa-1a2b' + +/** Every WebSocket the app would have opened, and what it sent on each. */ +class RecordingWebSocket { + static instances: RecordingWebSocket[] = [] + binaryType = '' + onopen: (() => void) | null = null + onclose: (() => void) | null = null + onmessage: ((e: { data: unknown }) => void) | null = null + sent: Array = [] + constructor(readonly url: string) { + RecordingWebSocket.instances.push(this) + } + send(data: string | ArrayBuffer | Uint8Array) { + this.sent.push(data) + } + close() {} +} + +/** The loopback welcome: this machine's name, its relay slot, and the origin + * a loopback tab can learn nowhere else. */ +const loopbackWelcome = (): Welcome => + welcome({ + status: 'connected', + origin: 'https://relay.example', + machineId: MESA_SLOT, + machineName: 'Blue Mesa', + }) + +describe('a loopback tab that never ran a ceremony', () => { + beforeEach(() => { + localStorage.clear() + sessionStorage.clear() + vi.stubGlobal('indexedDB', new IDBFactory()) + RecordingWebSocket.instances = [] + vi.stubGlobal('WebSocket', RecordingWebSocket) + }) + + /** The fleet a loopback tab builds: one local source, and the two seams only + * that tab is given (FleetOptions). Nothing else is scripted — the + * expansion, the key store and the certificate checks are the real ones. */ + function loopbackFleet( + enrol: () => Promise, + directory: ReturnType, + ) { + const local = new FakeClient() + const fleet = new FleetClient([src(LOCAL_MACHINE_ID, '', local)], undefined, { + enrol, + directoryFetch: directory, + }) + const seen: Array<{ sessions: FleetSession[]; machines: MachineState[] }> = [] + fleet.onFleet((sessions, machines) => seen.push({ sessions, machines })) + return { fleet, local, seen, last: () => seen[seen.length - 1]! } + } + + it('ends up holding every machine in the fleet, from a store with nothing in it', async () => { + // The whole point, in one case. Mac A has flue, a relay and a phone paired + // against it; Mac B ran the join line. The user opens flue on A's own + // address, where this browser has never paired with anything and every + // pairing link points at the relay — a different origin and a different + // storage partition, so nothing it could click would help. + expect(listMachines()).toEqual([]) + expect(await loadPinnedFleetKey()).toBeNull() + expect(await loadPinnedDeviceCert()).toBeNull() + + const key = await loadOrCreateDeviceKey() + const cert = deviceCert(key.publicKey, 'mesa — this machine’s browser', MESA_SLOT) + const post = enrolFetch(enrolAnswer(cert, FLEET_PUB, MESA_SLOT)) + const directory = directoryFetch([ + machineCert(MESA_SLOT, 'Blue Mesa', DAEMON_PUB), // this machine: the twin + machineCert('loft-9f9f', 'Loft', LOFT_PUB), // Mac B, joined this morning + ]) + + const h = loopbackFleet(() => enrolThisBrowser(post), directory) + h.fleet.connect() + h.local.open() + // The welcome lands while the enrolment is still going through IndexedDB, + // which is the real order and the one that used to leave the tab empty: the + // expansion it triggers reads no directory, because there is no fleet key + // yet. What repairs it is the enrolment's own rebuild. + h.local.emitWelcome(loopbackWelcome()) + + await vi.waitFor(() => expect(h.fleet.clientFor('loft-9f9f')).not.toBeNull()) + + // The two records, kept: pinned as they are by every other path. + expect(await loadPinnedFleetKey()).toEqual(FLEET_PUB) + expect(await loadPinnedDeviceCert()).toEqual(cert) + // Mac B is a machine of this fleet now, and Mac A appears once — as the + // loopback ride, not a second time through the relay. + expect(h.last().machines.map((m) => m.id)).toEqual([LOCAL_MACHINE_ID, 'loft-9f9f']) + expect(h.last().machines[0]!.name).toBe('Blue Mesa') + + // And it reaches B the way B admits a device it never met: its own relay + // slot, sealed to the key B's certificate names, carrying the certificate + // this machine just signed (channel.go, rule 2). Clicking a row of B's on + // this screen is exactly this client, resolved by machine id. + expect(RecordingWebSocket.instances.map((w) => w.url)).toEqual([ + 'wss://relay.example/client/loft-9f9f', + ]) + const ws = RecordingWebSocket.instances[0]! + ws.onopen?.() + const msgA = ws.sent.find((d): d is Uint8Array => typeof d !== 'string') + const loft = responderHandshake(LOFT_PRIV) + expect(loft.readMessageA(new Uint8Array(msgA!))).toEqual(key.publicKey) + expect(loft.payload()).toEqual(cert) + + // Nothing left for the sessions screen to apologise for. + expect(h.fleet.gaps()).toEqual({ uncertified: 0, fleetKey: true, pinned: 0 }) + h.fleet.close() + }) + + it('gets there too when the enrolment lands before the welcome', async () => { + // The other side of the race, and the reason the enrolment does not expand + // on its own: with no relay origin yet there is nothing to expand into, and + // the welcome that brings one finds both records already in the store. + const key = await loadOrCreateDeviceKey() + const cert = deviceCert(key.publicKey, 'mesa', MESA_SLOT) + const directory = directoryFetch([machineCert('loft-9f9f', 'Loft', LOFT_PUB)]) + const post = enrolFetch(enrolAnswer(cert, FLEET_PUB, MESA_SLOT)) + + const h = loopbackFleet(() => enrolThisBrowser(post), directory) + h.fleet.connect() + h.local.open() + await vi.waitFor(async () => expect(await loadPinnedDeviceCert()).toEqual(cert)) + expect(directory.calls).toEqual([]) // no origin, so nothing was read + + h.local.emitWelcome(loopbackWelcome()) + + await vi.waitFor(() => + expect(h.last().machines.map((m) => m.id)).toEqual([LOCAL_MACHINE_ID, 'loft-9f9f']), + ) + // One read of the directory, not two: the welcome's expansion had the + // records in hand, so nothing had to be repaired afterwards. + expect(directory.calls).toHaveLength(1) + h.fleet.close() + }) + + it('enrols once per epoch and asks again on the next load', async () => { + // Idempotent on the daemon's side by lookup, so the browser asks on each + // load rather than remembering; what it must not do is ask on a loop, which + // is what a tab that treated 409 as retryable would become. + const key = await loadOrCreateDeviceKey() + const cert = deviceCert(key.publicKey, 'mesa', MESA_SLOT) + const post = enrolFetch(enrolAnswer(cert, FLEET_PUB, MESA_SLOT)) + const directory = directoryFetch([machineCert('loft-9f9f', 'Loft', LOFT_PUB)]) + + const h = loopbackFleet(() => enrolThisBrowser(post), directory) + h.fleet.connect() + h.local.open() + h.local.emitWelcome(loopbackWelcome()) + await vi.waitFor(() => expect(h.fleet.clientFor('loft-9f9f')).not.toBeNull()) + const reads = directory.calls.length + expect(post.calls).toHaveLength(1) + + // A reconnect replays the welcome. Nothing is gained by it, so nothing is + // asked and nothing is rebuilt. + h.local.emitWelcome(loopbackWelcome()) + await flush() + expect(post.calls).toHaveLength(1) + expect(directory.calls).toHaveLength(reads) + + // The next page load is a second epoch: it asks again, is told the same + // thing, and gains nothing — so it does not re-read the directory either. + h.fleet.close() + h.fleet.connect() + await flush() + expect(post.calls).toHaveLength(2) + h.fleet.close() + }) + + it('is a working one-machine tab when this machine holds no fleet key', async () => { + // 409: the machine has not joined a relay, or joined one before fleet keys + // existed. Nothing is stored, nothing is retried, and the tab is the tab + // loopback has always been — which is the failure mode this whole path had + // to keep intact. + const post = enrolFetch('this machine holds no fleet key', { ok: false, status: 409 }) + const directory = directoryFetch([machineCert('loft-9f9f', 'Loft', LOFT_PUB)]) + + const h = loopbackFleet(() => enrolThisBrowser(post), directory) + h.fleet.connect() + h.local.open() + h.local.emitWelcome(loopbackWelcome()) + await vi.waitFor(() => expect(h.fleet.gaps()).not.toBeNull()) + + expect(await loadPinnedFleetKey()).toBeNull() + // No fleet key, so no directory read at all: an unverifiable machine list + // is a relay naming whatever machines it likes. + expect(directory.calls).toEqual([]) + expect(post.calls).toHaveLength(1) + expect(h.fleet.gaps()).toEqual({ uncertified: 0, fleetKey: false, pinned: 0 }) + + // And this machine's own sessions are exactly where they were. + h.local.emitSessions([info('s1')]) + expect(h.last().sessions).toEqual([ + { ...info('s1'), machineId: LOCAL_MACHINE_ID, machineName: 'Blue Mesa' }, + ]) + expect(h.last().machines).toEqual([ + { id: LOCAL_MACHINE_ID, name: 'Blue Mesa', status: 'online' }, + ]) + h.fleet.close() + }) + + it('keeps the machine it is on when the relay cannot be read', async () => { + // 502 from the daemon's proxy: the fault is upstream of this machine — the + // relay is down, or the leg is mid-dial — and the tab is told so rather + // than being handed a fabricated empty directory. The cost is machines this + // browser does not learn of; the machine it is sitting on is untouched. + const key = await loadOrCreateDeviceKey() + const cert = deviceCert(key.publicKey, 'mesa', MESA_SLOT) + const post = enrolFetch(enrolAnswer(cert, FLEET_PUB, MESA_SLOT)) + const directory = directoryFetch([], { ok: false, status: 502 }) + + const h = loopbackFleet(() => enrolThisBrowser(post), directory) + h.fleet.connect() + h.local.open() + h.local.emitWelcome(loopbackWelcome()) + await vi.waitFor(() => expect(directory.calls.length).toBeGreaterThan(0)) + + expect(h.last().machines).toEqual([ + { id: LOCAL_MACHINE_ID, name: 'Blue Mesa', status: 'online' }, + ]) + // Enrolment still landed, so the next read — a tick from now, or the next + // load — has everything it needs. + expect(await loadPinnedFleetKey()).toEqual(FLEET_PUB) + expect(await loadPinnedDeviceCert()).toEqual(cert) + + h.local.emitSessions([info('s1')]) + expect(h.last().sessions).toEqual([ + { ...info('s1'), machineId: LOCAL_MACHINE_ID, machineName: 'Blue Mesa' }, + ]) + h.fleet.close() + }) + + it('reads the relay’s own origin when nothing hands it a fetch', async () => { + // The relay tab's half of the same seam, asserted where it is decided: with + // no directoryFetch the expansion makes the plain cross-origin read it + // always made, at the relay's address. A loopback tab is the only one that + // goes through its daemon, and only because the Worker answers a + // cross-origin fetch without the header a browser needs to hand it over. + await savePinnedFleetKey(FLEET_PUB) + const key = await loadOrCreateDeviceKey() + await savePinnedDeviceCert(deviceCert(key.publicKey)) + const urls: string[] = [] + vi.stubGlobal('fetch', (url: string) => { + urls.push(url) + return Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve('{"v":1,"entries":[]}'), + }) + }) + + const local = new FakeClient() + const fleet = new FleetClient([src(LOCAL_MACHINE_ID, '', local)]) + fleet.connect() + local.open() + local.emitWelcome(loopbackWelcome()) + + await vi.waitFor(() => expect(urls).toEqual(['https://relay.example/directory'])) + fleet.close() + }) +}) + +// --------------------------------------------------------------------------- +// Live discovery: a fleet is not a set fixed at page load, and a tab left open +// for a day should not be the last to know. +// --------------------------------------------------------------------------- + +describe('discovery', () => { + beforeEach(() => { + localStorage.clear() + sessionStorage.clear() + vi.stubGlobal('indexedDB', new IDBFactory()) + }) + + it('picks up a machine that joined after the tab was opened', async () => { + vi.useFakeTimers() + const loft = new FakeClient() + const mesa = new FakeClient() + let answer: FleetSource[] = [src('loft-9f9f', 'Loft', loft)] + const expand = vi.fn(() => Promise.resolve([...answer])) + const h = harness([[LOCAL_MACHINE_ID, '']], expand) + h.fleet.connect() + h.fake(LOCAL_MACHINE_ID).open() + h.fake(LOCAL_MACHINE_ID).emitWelcome( + welcome({ status: 'connected', origin: 'https://relay.example' }), + ) + await vi.advanceTimersByTimeAsync(0) + expect(h.last().machines.map((m) => m.id)).toEqual([LOCAL_MACHINE_ID, 'loft-9f9f']) + + // Somebody runs the join line on a second machine. Nothing tells this tab; + // the directory is where it turns up, and something has to read it again. + answer = [src('loft-9f9f', 'Loft', loft), src('blue-mesa-1a2b', 'Blue Mesa', mesa)] + await vi.advanceTimersByTimeAsync(60_000) + + expect(expand).toHaveBeenCalledTimes(2) + expect(h.last().machines.map((m) => m.id)).toEqual([ + LOCAL_MACHINE_ID, + 'loft-9f9f', + 'blue-mesa-1a2b', + ]) + // Adopted whole: dialled once, and the one already held was not dialled a + // second time. + expect(mesa.connects).toBe(1) + expect(loft.connects).toBe(1) + h.fleet.close() + }) + + it('drops nothing when a read comes back empty or fails outright', async () => { + // The contract that makes re-reading safe at all. A relay having a bad + // minute, a directory that answered 502, a key store that would not open — + // none of them is evidence that a machine left the fleet, and a screen that + // emptied itself on one would be worse than a screen that was late. + vi.useFakeTimers() + const loft = new FakeClient() + let answer: FleetSource[] = [src('loft-9f9f', 'Loft', loft)] + const expand = vi.fn(() => Promise.resolve([...answer])) + const h = harness([[LOCAL_MACHINE_ID, '']], expand) + h.fleet.connect() + h.fake(LOCAL_MACHINE_ID).open() + h.fake(LOCAL_MACHINE_ID).emitWelcome( + welcome({ status: 'connected', origin: 'https://relay.example' }), + ) + await vi.advanceTimersByTimeAsync(0) + loft.open() + loft.emitSessions([info('r1')]) + const held = h.last().machines + + answer = [] + await vi.advanceTimersByTimeAsync(60_000) + expect(h.last().machines).toEqual(held) + + expand.mockRejectedValueOnce(new Error('the key store would not open')) + await vi.advanceTimersByTimeAsync(60_000) + expect(h.last().machines).toEqual(held) + // And its rows are still on the screen: nothing was closed, so nothing was + // asked to reconnect. + expect(h.last().sessions.map((s) => s.machineId)).toEqual(['loft-9f9f']) + expect(loft.closes).toBe(0) + h.fleet.close() + }) + + it('asks again when the tab is looked at, but not on every glance', async () => { + // Background tabs are throttled to a crawl, so the interval alone would + // mean the reader comes back to a stale fleet and waits. The floor is what + // keeps alt-tabbing from being a directory read per switch. + vi.useFakeTimers() + const expand = vi.fn(() => Promise.resolve([] as FleetSource[])) + const h = harness([[LOCAL_MACHINE_ID, '']], expand) + h.fleet.connect() + h.fake(LOCAL_MACHINE_ID).open() + h.fake(LOCAL_MACHINE_ID).emitWelcome( + welcome({ status: 'connected', origin: 'https://relay.example' }), + ) + await vi.advanceTimersByTimeAsync(0) + expect(expand).toHaveBeenCalledTimes(1) + + // Straight back to the tab: the expansion it just ran is the answer. + window.dispatchEvent(new Event('focus')) + await vi.advanceTimersByTimeAsync(0) + expect(expand).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(6_000) + window.dispatchEvent(new Event('focus')) + await vi.advanceTimersByTimeAsync(0) + expect(expand).toHaveBeenCalledTimes(2) + + // And a closed fleet hears nothing: no interval, no listener left on the + // window, nothing dialled by a tab on its way out. + h.fleet.close() + await vi.advanceTimersByTimeAsync(120_000) + window.dispatchEvent(new Event('focus')) + await vi.advanceTimersByTimeAsync(0) + expect(expand).toHaveBeenCalledTimes(2) + }) + + it('reads nothing on a tab whose daemon has named no relay', async () => { + vi.useFakeTimers() + const expand = vi.fn(() => Promise.resolve([] as FleetSource[])) + const h = harness([[LOCAL_MACHINE_ID, '']], expand) + h.fleet.connect() + h.fake(LOCAL_MACHINE_ID).open() + h.fake(LOCAL_MACHINE_ID).emitWelcome(welcome()) + + await vi.advanceTimersByTimeAsync(180_000) + window.dispatchEvent(new Event('focus')) + await vi.advanceTimersByTimeAsync(0) + + expect(expand).not.toHaveBeenCalled() + h.fleet.close() + }) +}) diff --git a/web/src/fleet/fleet.ts b/web/src/fleet/fleet.ts index f21f268..2d94571 100644 --- a/web/src/fleet/fleet.ts +++ b/web/src/fleet/fleet.ts @@ -68,6 +68,34 @@ const POLL_MS = 3_000 */ const STAGGER_MS = 150 +/** + * How often an open tab asks the fleet directory again. + * + * The expansion used to be one-shot per epoch, which was right when the only + * thing that could change the answer was a record arriving on a welcome. It is + * not: a machine that runs the join line this afternoon appears in the + * directory this afternoon, and a tab that has been open since this morning + * would never hear of it — the fleet's own screens promise "every machine", + * and a promise kept only across a reload is not one. + * + * A minute, and not the three seconds the session poll runs at, because this is + * a different question with a different rate of change: sessions come and go + * while somebody watches, machines join a fleet a handful of times in their + * life. Every read is additive (see `adoptRemotes`), so the cost of being late + * is bounded and the cost of a failed read is nothing at all. + */ +const DISCOVER_MS = 60_000 + +/** + * The shortest gap between two directory reads, whatever asks for them. + * + * Focus is the other trigger, and focus is not rationed by anything: a reader + * alt-tabbing between a terminal and this tab would otherwise spend a directory + * read per switch. The floor is what makes "ask again when the tab is looked + * at" safe to offer. + */ +const DISCOVER_FLOOR_MS = 5_000 + /** One machine the fleet should hold: its id, its label, its built client. */ export interface FleetSource { id: string @@ -137,6 +165,36 @@ export interface FleetGaps { pinned: number } +/** + * The two things only a loopback tab supplies, injected rather than sniffed. + * + * Both are facts about the *page's* origin rather than about any machine, and + * this module deliberately reads no `location` — the relay origin arrives from + * the caller on a relay tab and from the daemon's welcome on a loopback one, + * and the same discipline applies to these. They travel from src/main.tsx + * through the router's context to the provider, which is the one place that + * knows which of the two ways this page was served. + */ +export interface FleetOptions { + /** + * How this browser becomes a device of the fleet, on a tab that never ran a + * ceremony because it never needed one. Run once per epoch at `connect`; see + * fleet/enrol.ts, which is where the whole argument for it lives. + * + * Absent on a relay tab and in every test that does not say otherwise, which + * is the honest default: enrolment is a loopback-only endpoint, and a tab + * that reached this app any other way has already paired. + */ + enrol?: () => Promise + /** + * How the default expansion reads the fleet directory. Absent means the + * plain cross-origin read straight off the relay, which is what a relay tab + * does and always did; a loopback tab passes the daemon's proxy, because the + * Worker sends no CORS header and the browser discards its answer. + */ + directoryFetch?: DirectoryFetch +} + /** What onFleet hands its listeners, on any change to either half. */ type FleetListener = (sessions: FleetSession[], machines: MachineState[]) => void @@ -212,6 +270,12 @@ export class FleetClient { private errorListeners: ErrorListener[] = [] private running = false private poll: ReturnType | null = null + /** The slower interval that re-reads the directory; see DISCOVER_MS. */ + private discovery: ReturnType | null = null + /** When the last directory read was asked for, for DISCOVER_FLOOR_MS. */ + private lastDiscover = 0 + /** The focus handler, held so close can take it off the window again. */ + private readonly onFocus = () => this.discover() /** The stagger timers of the current tick, so close leaves none armed. */ private staggers = new Set>() /** Whether this epoch has already built remotes from a learned origin. */ @@ -223,15 +287,20 @@ export class FleetClient { */ private relayOrigin: string | null = null /** - * Whether something re-supplied on a welcome has already forced that second - * expansion — a fleet key, a certificate, or the two together. + * Whether records that arrived mid-epoch have already forced that second + * expansion — a fleet key, a certificate, or the two together, off a welcome + * or out of an enrolment. * * Once per epoch: a browser gains machines the first time it holds each of * them, and re-running on every later welcome would be a directory read per - * reconnect for a set that cannot have changed. One flag covers both because - * they cannot arrive apart in a way that would strand the second — a + * reconnect for a set that cannot have changed. One flag covers all of them + * because they cannot arrive apart in a way that would strand the last — a * certificate is verified under the fleet key, so a browser missing both - * gains them in that order, on the one welcome, before this is consulted. + * gains them in that order, on the one welcome or in the one enrolment + * answer, before this is consulted. + * + * It bounds the *repair*, not discovery: `discover` below re-reads on its own + * schedule for machines that join later, and is not gated on this. */ private resupplied = false /** The slot id the loopback daemon holds on the relay, once known. */ @@ -252,8 +321,17 @@ export class FleetClient { */ private readonly expand: (relayOrigin: string) => Promise - constructor(sources: FleetSource[], expand?: (relayOrigin: string) => Promise) { + /** This tab's way of becoming a fleet device, or null where there is none + * to be had. See FleetOptions.enrol. */ + private readonly enrol: (() => Promise) | null + + constructor( + sources: FleetSource[], + expand?: (relayOrigin: string) => Promise, + opts: FleetOptions = {}, + ) { this.slots = sources.map(toSlot) + this.enrol = opts.enrol ?? null // Assigned in the body rather than as a parameter default because the // production builder reports back into this instance: what it could not // build is as much a part of the fleet as what it could. @@ -264,6 +342,10 @@ export class FleetClient { loopback: false, relayOrigin: origin, onGaps: (g) => this.noteGaps(g), + // Passed through rather than decided down there, because which read + // works is a fact about the origin serving this page and fleetSources + // is handed a relay origin and nothing else. + ...(opts.directoryFetch !== undefined && { directoryFetch: opts.directoryFetch }), })) } @@ -306,6 +388,17 @@ export class FleetClient { for (const slot of slots) this.wire(slot) for (const slot of slots) slot.client.connect() this.poll = setInterval(() => this.pollTick(), POLL_MS) + this.discovery = setInterval(() => this.discover(), DISCOVER_MS) + // A background tab's timers are throttled to a crawl, so the interval alone + // would mean a machine that joined an hour ago appears some time after the + // reader comes back rather than as they arrive. Same trade, same reason, as + // useRefetchOnFocus on the sessions screen. + if (typeof window !== 'undefined') window.addEventListener('focus', this.onFocus) + // Before anything is expanded, and not awaited: the records it may bring + // back are what the expansion reads, but the welcome that names a relay has + // not arrived either, and the two orders both end in one rebuild — see + // `runEnrolment`. + if (this.enrol !== null) void this.runEnrolment(this.enrol) } /** @@ -322,10 +415,16 @@ export class FleetClient { this.expanded = false this.resupplied = false this.relayOrigin = null + this.lastDiscover = 0 if (this.poll !== null) { clearInterval(this.poll) this.poll = null } + if (this.discovery !== null) { + clearInterval(this.discovery) + this.discovery = null + } + if (typeof window !== 'undefined') window.removeEventListener('focus', this.onFocus) for (const t of this.staggers) clearTimeout(t) this.staggers.clear() for (const slot of this.slots) { @@ -632,6 +731,10 @@ export class FleetClient { */ private async adoptRemotes(origin: string) { const epoch = this.epoch + // Every expansion counts against the discovery floor, whoever asked for it: + // a tab that has just read the directory on a welcome has no more to learn + // from reading it again because somebody clicked back into the window. + this.lastDiscover = Date.now() let built: FleetSource[] try { built = await this.expand(origin) @@ -654,6 +757,69 @@ export class FleetClient { if (changed) this.emit() } + /** + * Become a device of this fleet, on the one kind of tab that has to ask. + * + * The two orders this has to survive, because the enrolment and the daemon's + * welcome race and neither wins reliably: + * + * - **Records first.** `relayOrigin` is still null — the welcome that names + * it has not landed — so there is nothing to expand into and nothing to + * do. The welcome arrives a moment later and `localWelcome` expands with + * the fleet key and the certificate already in the store. + * - **Welcome first.** The expansion has already run, and ran blind: with + * no fleet key it read no directory at all and built the machines this + * browser had pinned, which on a loopback tab is none. So this rebuilds, + * through the same `resupplied` gate the welcome path uses, which is what + * bounds the pair to one extra directory read per epoch. + * + * Nothing is retried and nothing is scheduled. A daemon that cannot enrol + * this browser — no fleet key, no machine id, a registry it cannot write — + * will not be able to a second later either, and the tab it leaves behind is + * the tab loopback has always been: this machine, listed and working. + */ + private async runEnrolment(enrol: () => Promise) { + const epoch = this.epoch + let gained: boolean + try { + gained = await enrol() + } catch { + // enrolThisBrowser answers rather than throws; a caller's seam might not. + return + } + if (!gained || epoch !== this.epoch || !this.running) return + if (this.resupplied) return + const origin = this.relayOrigin + if (origin === null) return + this.resupplied = true + await this.adoptRemotes(origin) + } + + /** + * Ask the fleet directory again, for the machines that were not in it last + * time. + * + * **Additive, and that is the whole contract.** `adoptRemotes` adds ids it + * does not already hold and removes nothing, so a read that comes back empty, + * short, or as a 502 from a relay having a bad minute costs exactly nothing — + * every machine already on screen keeps its client, its rows and its status. + * A machine that genuinely left the fleet is a slot that goes unreachable, + * which is a thing the screens already say, and re-reading a directory is not + * where that verdict belongs. + * + * Silent before a relay origin is known: a tab whose daemon has named no + * relay has no directory to read and nothing to discover. + */ + private discover() { + if (!this.running) return + const origin = this.relayOrigin + if (origin === null) return + const now = Date.now() + if (now - this.lastDiscover < DISCOVER_FLOOR_MS) return + this.lastDiscover = now + void this.adoptRemotes(origin) + } + /** Remove one slot outright: unhooked, closed, and out of every next emit. */ private drop(slot: Slot) { for (const off of slot.unsubs) off() diff --git a/web/src/fleet/provider.test.tsx b/web/src/fleet/provider.test.tsx index 68991c5..3c0bfcf 100644 --- a/web/src/fleet/provider.test.tsx +++ b/web/src/fleet/provider.test.tsx @@ -1,10 +1,12 @@ import { StrictMode, useEffect } from 'react' -import { render } from '@testing-library/react' +import { act, render } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' +import { IDBFactory } from 'fake-indexeddb' import type { ConnStatus, FlueClient } from '@/client/client' import { FlueClientProvider, useFlueClient } from '@/client/provider' import { fakeClient } from '@/testing/socket' +import { ENROL_PATH } from './enrol' import { FleetClient, type FleetSource } from './fleet' import { FleetProvider, useFleet } from './provider' import { LOCAL_MACHINE_ID, type MachineState } from './types' @@ -232,6 +234,47 @@ describe('FleetProvider', () => { expect(instances[1]!.closed).toBe(false) }) + it('enrols this browser only on the tab the daemon served', async () => { + // The flag arrives from src/main.tsx through the router's context, and the + // difference it makes is where a device key gets posted: at the machine + // this page came from, or at whatever else answered. A tab that guessed + // from `client === undefined` would get this wrong for every test client + // and every future caller. + class FakeWebSocket { + binaryType = '' + onopen: unknown = null + onclose: unknown = null + onmessage: unknown = null + send() {} + close() {} + } + vi.stubGlobal('WebSocket', FakeWebSocket) + vi.stubGlobal('indexedDB', new IDBFactory()) + const posts: string[] = [] + vi.stubGlobal('fetch', (url: string) => { + posts.push(url) + // 404, so nothing is stored and no directory is read: this case is about + // who asks, not what comes back. + return Promise.resolve({ ok: false, status: 404, text: () => Promise.resolve('') }) + }) + + const plain = render( + + + , + ) + await act(async () => {}) + expect(posts).toEqual([]) + plain.unmount() + + render( + + + , + ) + await vi.waitFor(() => expect(posts).toEqual([ENROL_PATH])) + }) + it('adopts a client already in context as the local ride', () => { // A client above the fleet is the tab's ride — a test's scripted client, // exactly as router.test.tsx mounts one — so the fleet must fold it in diff --git a/web/src/fleet/provider.tsx b/web/src/fleet/provider.tsx index f21b9dc..34e8721 100644 --- a/web/src/fleet/provider.tsx +++ b/web/src/fleet/provider.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, useEffect, useRef, type ReactNode } from 're import { daemonSocketUrl, FlueClient } from '@/client/client' import { FlueClientContext } from '@/client/provider' +import { enrolThisBrowser, readDirectoryViaDaemon } from './enrol' import { FleetClient } from './fleet' import { LOCAL_MACHINE_ID } from './types' @@ -29,6 +30,22 @@ export interface FleetProviderProps { * machine and no other. */ pinned?: boolean + /** + * Whether this page was served by the daemon on its own loopback origin. + * + * It unlocks the two things only such a tab can do, and only such a tab needs + * (fleet/enrol.ts): asking this machine to enrol it as a device of the fleet, + * and reading the fleet directory through the daemon rather than making a + * cross-origin fetch the relay answers without a CORS header. + * + * Passed in from src/main.tsx through the router's context rather than worked + * out here, because it is the same one fact `client` and `pinned` describe — + * how this page was served — and the entry point is where that is known. It + * is deliberately not `client === undefined`: a test putting a scripted + * client in context is not a loopback tab, and would post a device key at + * whatever answered. + */ + loopback?: boolean } /** @@ -45,11 +62,11 @@ export interface FleetProviderProps { * That is what lets a test put a scripted fleet above the router and still * exercise the real tree. */ -export function FleetProvider({ children, fleet, client, pinned }: FleetProviderProps) { +export function FleetProvider({ children, fleet, client, pinned, loopback }: FleetProviderProps) { const inherited = useContext(FleetContext) if (fleet === undefined && inherited !== null) return <>{children} return ( - + {children} ) @@ -81,24 +98,34 @@ export function FleetProvider({ children, fleet, client, pinned }: FleetProvider * already holds a socket keeps that one and opens no second, a client waiting * out a backoff dials early rather than twice, and both closes close once. */ -function OwnFleetProvider({ children, fleet, client, pinned }: FleetProviderProps) { +function OwnFleetProvider({ children, fleet, client, pinned, loopback }: FleetProviderProps) { const legacy = useContext(FlueClientContext) const own = useRef(null) let active = fleet if (!active) { - own.current ??= new FleetClient([ - { - id: LOCAL_MACHINE_ID, - name: '', - client: client ?? legacy ?? new FlueClient(daemonSocketUrl()), - // Only ever true of the ride the entry point built, and only when it - // said so. The other two rides are a loopback socket and whatever a - // test put in context; neither is keyed to a pinned daemon key, and a - // flag that travelled without its client would be a claim about the - // wrong one. See FleetSource.pinned. - pinned: client !== undefined && pinned === true, - }, - ]) + own.current ??= new FleetClient( + [ + { + id: LOCAL_MACHINE_ID, + name: '', + client: client ?? legacy ?? new FlueClient(daemonSocketUrl()), + // Only ever true of the ride the entry point built, and only when it + // said so. The other two rides are a loopback socket and whatever a + // test put in context; neither is keyed to a pinned daemon key, and a + // flag that travelled without its client would be a claim about the + // wrong one. See FleetSource.pinned. + pinned: client !== undefined && pinned === true, + }, + ], + // The production expansion, which the fleet builds for itself so it can + // hear what that build could not reach. + undefined, + // And the two seams a loopback tab needs it to use, from the one place + // that knows this page came off the daemon's own origin. + loopback === true + ? { enrol: enrolThisBrowser, directoryFetch: readDirectoryViaDaemon } + : {}, + ) active = own.current } diff --git a/web/src/main.tsx b/web/src/main.tsx index aa7f22a..7f734bd 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -36,11 +36,18 @@ if (cleaned !== location.href) history.replaceState(null, '', cleaned) * same deployment by construction, and a URL from anywhere else would be a * second thing to keep true. * - * On the daemon's own origin nothing is awaited and nothing is passed: the - * router mounts the provider it always did, and that builds the loopback - * client itself. + * On the daemon's own origin nothing is awaited and no client is passed: the + * router mounts the provider it always did, and that builds the loopback client + * itself. What it *is* told is that this is that origin — the one fact a tab + * cannot work out from below, and the one the fleet needs before it can enrol + * itself as a device of this machine's fleet or read the fleet directory + * through the daemon (see src/fleet/enrol.ts). It is decided here, beside the + * relay branch it is the alternative to, so there is one place in the app where + * "how was this page served" is answered. */ -const router = createFlueRouter(isRelayOrigin() ? await relayBoot(location.origin) : {}) +const router = createFlueRouter( + isRelayOrigin() ? await relayBoot(location.origin) : { loopback: true }, +) const root = document.getElementById('root') if (!root) throw new Error('missing #root') diff --git a/web/src/router.tsx b/web/src/router.tsx index 5d807d0..0bad423 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -56,6 +56,17 @@ export interface FlueRouterOptions { * attempt until it has an answer. */ picker?: boolean + /** + * True when the daemon on this machine served this page: the other side of + * the same coin as `client`, and the case where the tab holds a session + * cookie and no fleet identity at all. + * + * The fleet uses it for the two things only that tab does — enrolling itself + * as a device of this machine's fleet, and reading the fleet directory + * through the daemon, because the relay answers a cross-origin fetch without + * the header a browser needs to hand it over. See fleet/enrol.ts. + */ + loopback?: boolean } /** @@ -93,11 +104,11 @@ export interface FlueRouterOptions { const rootRoute = createRootRouteWithContext()({ component: function Root() { const pathname = useRouterState({ select: (s) => s.location.pathname }) - const { client, pinned, picker } = rootRoute.useRouteContext() + const { client, pinned, picker, loopback } = rootRoute.useRouteContext() if (pathname === PAIR_PATH) return if (picker === true) return return ( - + ) diff --git a/web/src/routes/sessions.test.tsx b/web/src/routes/sessions.test.tsx index 5be366f..f7e4e4c 100644 --- a/web/src/routes/sessions.test.tsx +++ b/web/src/routes/sessions.test.tsx @@ -508,8 +508,11 @@ describe('SessionsRoute', () => { // // This tab has performed no ceremony at all — a loopback tab, where the // pairing link the daemon draws points at the relay's address rather - // than this one — so nothing is going to hand it a key and the band says - // where to go. + // than this one, so the ceremony the band used to name could never have + // admitted *this* browser. What such a tab has instead is enrolment on + // every load (fleet/enrol.ts), and reaching this band despite it means + // the machine itself holds no fleet key — which is what the band now + // says, because it is the only thing anybody can act on. vi.stubGlobal('indexedDB', new IDBFactory()) localStorage.clear() const { sock } = await mountSessions() @@ -527,8 +530,8 @@ describe('SessionsRoute', () => { }), ) - await waitFor(() => expect(screen.getByText(/has\s+paired with none/)).toBeTruthy()) - expect(screen.getByText(/pair there/)).toBeTruthy() + await waitFor(() => expect(screen.getByText(/holds\s+no key for the fleet/)).toBeTruthy()) + expect(screen.getByText(/then reload/)).toBeTruthy() }) it('does not tell a browser to pair again when a machine is about to hand it a key', async () => { diff --git a/web/src/routes/sessions.tsx b/web/src/routes/sessions.tsx index c712d3f..569e9d2 100644 --- a/web/src/routes/sessions.tsx +++ b/web/src/routes/sessions.tsx @@ -855,14 +855,19 @@ function PlacedBulkBar(props: { * ceremonies there is a machine that can hand it over and has not yet — it is * out of reach, or it holds no fleet key of its own — and the sentence says so * without pretending to know which; while it is still on its way, that same - * sentence is the honest description of a repair in progress. At zero there is - * nobody to ask, which is the tab riding a machine it never paired with — a - * loopback tab, where the ceremony's link points at the relay's address rather - * than this one — and a ceremony is the whole of the way out. + * sentence is the honest description of a repair in progress. * - * Silent when there is nothing to say, which is the ordinary case: a browser - * that pinned a fleet key and holds a certificate has no gap, and a band - * reading "everything is fine" is a band nobody reads. + * At zero ceremonies the tab is riding a machine it never paired with, which in + * practice is the loopback tab: a relay tab boots from a pinned key and cannot + * get here. That case used to be told to go and pair at the relay's address, + * and it is now the one case with a local answer — the machine's own daemon + * enrols its browser on every load (fleet/enrol.ts), so a browser still without + * a key is one whose machine has none to give. The sentence names that, because + * it is the only thing anybody can act on: join the relay from this machine. + * + * Silent when there is nothing to say, which is now the ordinary case + * everywhere — including on loopback, where enrolment is what closes the last + * two gaps. A band reading "everything is fine" is a band nobody reads. */ function FleetGapBand({ gaps }: { gaps: FleetGaps }) { if (!gaps.fleetKey && gaps.pinned === 0) { @@ -870,9 +875,10 @@ function FleetGapBand({ gaps }: { gaps: FleetGaps }) {

This tab is talking to{' '} - one machine and has - paired with none, so it holds no key for the fleet and cannot see the rest of it. Open - flue at the relay’s own address and pair there. + one machine and holds + no key for the fleet, so it cannot see the rest of it. A machine hands its own browser + one as soon as it has joined a relay — check flue status{' '} + on this machine, then reload.

) @@ -891,6 +897,18 @@ function FleetGapBand({ gaps }: { gaps: FleetGaps }) { ) } if (gaps.uncertified === 0) return null + /* + The way back used to be one sentence — pair again from any machine on the + fleet — and it is the wrong instruction for half the readers now. A tab on a + machine's own address cannot act on it at all: a pairing link points at the + relay, which is another origin and another storage partition, so the + ceremony would admit a browser that is not this one. What that tab has + instead is enrolment on every load, and reaching this band despite it means + the certificate this machine signed has been taken away — a revocation, + which is permanent for the key it names. Hence the clearing: a fresh key is + what gets enrolled next time. Both doors are named because the band cannot + see which side of it the reader is on. + */ return (

@@ -898,8 +916,9 @@ function FleetGapBand({ gaps }: { gaps: FleetGaps }) { {gaps.uncertified === 1 ? '1 machine' : `${gaps.uncertified} machines`} {' '} in this fleet {gaps.uncertified === 1 ? 'has' : 'have'} no certificate this browser can - present, so {gaps.uncertified === 1 ? 'it is' : 'they are'} not listed here. Pair this - browser again from any machine on the fleet to be let in. + present, so {gaps.uncertified === 1 ? 'it is' : 'they are'} not listed here. On this + machine’s own address, clearing this site’s storage and reloading gets a fresh one. From + anywhere else, pair this browser again from a machine on the fleet.

) diff --git a/web/src/testing/fleet.ts b/web/src/testing/fleet.ts index 137cfc5..36184ef 100644 --- a/web/src/testing/fleet.ts +++ b/web/src/testing/fleet.ts @@ -15,6 +15,7 @@ import { ed25519 } from '@noble/curves/ed25519.js' import fixture from '../../../testdata/fleet/certs.json' import { encodeCert, type Cert } from '@/crypto/cert' +import type { EnrolPost } from '@/fleet/enrol' import type { DirectoryAnswer, DirectoryFetch } from '@/relay/directory' const unhex = (s: string) => new Uint8Array((s.match(/.{2}/g) ?? []).map((b) => parseInt(b, 16))) @@ -115,6 +116,50 @@ export function directoryFetch( return Object.assign(fetch, { calls }) } +/** + * What a daemon holding this fleet's key answers an enrolment with — the four + * fields of daemon.enrolAnswer, around whatever certificate a case has minted. + * + * `deviceId` is a constant because nothing in the browser reads it: the row it + * names lives on the machine's Devices screen, and a browser that stored an id + * would be keeping a second copy of a fact only the daemon can answer for. + */ +export function enrolAnswer( + cert: Uint8Array, + fleetPub: Uint8Array = FLEET_PUB, + machineId = 'blue-mesa-1a2b', +) { + return { + deviceId: 'ab12cd34', + deviceCert: base64(cert), + fleetPub: base64(fleetPub), + machineId, + } +} + +/** + * A stand-in for `POST /api/fleet/enrol`, recording what was posted at it. + * + * The body is handed over as a string rather than parsed, so a case can assert + * the exact document that went on the wire — the endpoint reads one field and + * the encoding of it is the thing worth pinning. + */ +export function enrolFetch( + answer: Record | string, + init: { ok?: boolean; status?: number } = {}, +): EnrolPost & { calls: Array<{ path: string; body: string }> } { + const calls: Array<{ path: string; body: string }> = [] + const post = (path: string, body: string): Promise => { + calls.push({ path, body }) + return Promise.resolve({ + ok: init.ok ?? true, + status: init.status ?? 200, + text: () => Promise.resolve(typeof answer === 'string' ? answer : JSON.stringify(answer)), + }) + } + return Object.assign(post, { calls }) +} + /** The fixture's own device key, for cases that want the committed vectors * rather than a minted one. */ export const FIXTURE_DEVICE = unhex( From 090bfa77bfeebb6167b47bea4964251c0f573732 Mon Sep 17 00:00:00 2001 From: Karn Date: Mon, 10 Aug 2026 23:14:54 +0530 Subject: [PATCH 2/5] feat(web): say so when a deploy needs the tab reloaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/cloudflare-connect.test.tsx | 52 ++++++++++++++++++ web/src/components/cloudflare-connect.tsx | 55 +++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/web/src/components/cloudflare-connect.test.tsx b/web/src/components/cloudflare-connect.test.tsx index 51c9530..c07599c 100644 --- a/web/src/components/cloudflare-connect.test.tsx +++ b/web/src/components/cloudflare-connect.test.tsx @@ -118,6 +118,23 @@ describe('CloudflareConnectCard', () => { expect(screen.getByLabelText(/API token/)).toBeTruthy() }) + it('tells the tab that ran the deploy to reload, and offers the reload', async () => { + // The trap this closes. Three facts about this page were settled when the + // daemon served it and cannot be revised from here: its connect policy + // names no relay (LocalCSPFor), its fleet was told the relay leg was off + // and nothing broadcasts a change, and its enrolment was answered 409 by a + // machine that then had no fleet key. One reload fixes all three; without + // being told, the reader is left on a screen that says a relay exists and + // a sessions list that will never show another machine. + stubFetch([{ steps: ['worker deployed: flue-relay'], origin: 'https://r.example' }]) + render() + + await typeTokenAndDeploy('cf-token-4') + + expect(await screen.findByText(/loaded before the relay existed/)).toBeTruthy() + expect(screen.getByRole('button', { name: 'Reload flue' })).toBeTruthy() + }) + it('shows the reason instead of the form when the daemon cannot deploy', () => { render( { expect(screen.getByText('worker deployed: flue-relay')).toBeTruthy() expect(screen.getByRole('dialog')).toBeTruthy() }) + + it('does not ask for a reload, because this tab was served with the relay', async () => { + // The other side of the reload notice. A tab looking at the configured + // card was served by a daemon that already had relay.json, so its connect + // policy names the relay and its fleet learned the origin from the first + // welcome. Telling this reader to reload would be asking for something + // that buys them nothing. + stubFetch([ + { + configured: true, + can_deploy: true, + version: '0.3.0', + deployed_version: '0.2.0', + worker: 'flue-relay', + has_token: true, + }, + { steps: ['worker deployed: flue-relay'] }, + { + configured: true, + can_deploy: true, + version: '0.3.0', + deployed_version: '0.3.0', + worker: 'flue-relay', + has_token: true, + }, + ]) + render() + + const user = userEvent.setup() + await user.click(await screen.findByRole('button', { name: 'Update relay…' })) + await user.click(screen.getByRole('button', { name: 'Update relay' })) + + expect(await screen.findByText('worker deployed: flue-relay')).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Reload flue' })).toBeNull() + }) }) describe('RelayDisconnect', () => { diff --git a/web/src/components/cloudflare-connect.tsx b/web/src/components/cloudflare-connect.tsx index 790854d..1b8835a 100644 --- a/web/src/components/cloudflare-connect.tsx +++ b/web/src/components/cloudflare-connect.tsx @@ -228,6 +228,7 @@ function DeployFlow({ storedToken, accountName, onDone, + reloadAfter, }: { endpoint: string /** The verb on the button: "Deploy" or "Update relay". */ @@ -240,6 +241,11 @@ function DeployFlow({ accountName?: string /** Called once the deploy has landed, for a caller with something to close. */ onDone?: () => void + /** + * Whether the tab that ran this deploy has to be reloaded before it can use + * the relay it just created. True for the setup flow; see ReloadAfterDeploy. + */ + reloadAfter?: boolean }) { const [phase, setPhase] = useState('form') const [token, setToken] = useState('') @@ -306,6 +312,7 @@ function DeployFlow({ )} + {reloadAfter && } ) } @@ -398,6 +405,49 @@ function DeployFlow({ ) } +/** + * The one thing a successful first deploy cannot do for the tab that ran it. + * + * This page was served before the relay existed, and three separate facts about + * it were settled at that moment and cannot be revised from here: + * + * - **The Content-Security-Policy.** The daemon builds it from relay.json + * when it serves the document (internal/daemon/server.go, `LocalCSPFor`), + * so a page served without a relay carries a policy naming none — and the + * browser will block `wss:///client/` and the directory read + * whatever the app tries. A header cannot be changed after the fact, by + * anything. + * - **The relay origin.** A loopback tab learns it from the welcome, and + * `relayInfo()` reports nothing while the leg is off. Nothing broadcasts a + * change of relay status, so the tab's fleet would go on believing the last + * thing it was told for as long as it stayed open. + * - **This browser's fleet identity.** It enrols once per load + * (fleet/enrol.ts), and a tab loaded before the relay was answered 409: + * this machine held no fleet key to certify anything with. It does now. + * + * So the honest thing is to say so and offer the one act that fixes all three + * at once. It is a button rather than an automatic reload because the steps + * above it are the reader's only account of what was just done to their + * Cloudflare account, and a page that navigated away from its own receipt is + * the thing every other flow on this card is careful not to do. + */ +function ReloadAfterDeploy() { + return ( +
+

+ This page was loaded before the relay existed, so it cannot use it yet — what a page may + connect to is fixed when it is served. Reload to pick up the relay: the sessions on every + machine you join will be on this browser’s own list. +

+
+ +
+
+ ) +} + /** * The integration card for a daemon with no relay: what a deploy will create, * spelled out before any credential is asked for, then the form. The CLI @@ -439,6 +489,11 @@ export function CloudflareConnectCard({ defaultWorker={info?.worker || 'flue-relay'} storedToken={info?.has_token} accountName={info?.account_name} + // This card is the one that renders on a tab with no relay behind + // it, which is exactly the tab that has to be reloaded before it + // can use the one it just deployed. The update flow does not: that + // tab was served with the relay already named. + reloadAfter /> )}
From 612cd7242d53c981743beedac37db439b851c2a4 Mon Sep 17 00:00:00 2001 From: Karn Date: Mon, 10 Aug 2026 23:16:36 +0530 Subject: [PATCH 3/5] docs(spec): the third delivery of the fleet key, and why loopback earns one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- spec/fleet-trust.md | 51 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/spec/fleet-trust.md b/spec/fleet-trust.md index 478ab07..a646938 100644 --- a/spec/fleet-trust.md +++ b/spec/fleet-trust.md @@ -145,6 +145,57 @@ honours — so adopting the new one would trade a browser that lists what it can reach for one that lists what it cannot. Pairing again is what mints a certificate, and it stays the way out of that state. +### The third delivery: a machine's own browser + +Neither delivery above reaches the tab a user opened on +`http://127.0.0.1:7717`. It ran no ceremony because it never needed one — the +session cookie is its credential and it was only ever talking to the machine it +is on — so it holds no device cert to present to a sibling; and the rule above +excludes it by name, because a session cookie authenticates no key. Nor can it +be sent to the QR: a pairing link lands on the *relay's* origin, which is a +different storage partition, so the ceremony would admit a browser that is not +this one. Before this the fleet silently collapsed to one machine on a fully +joined laptop, and nothing on any screen said why. + +The daemon that served the page answers instead, on its loopback HTTP surface, +behind the same session token as everything else there: + +``` +POST /api/fleet/enrol body {publicKey} → {deviceId, deviceCert, fleetPub, machineId} +GET /api/fleet/directory the relay's own answer, byte for byte +``` + +**Enrolment grants no authority that the caller does not already hold.** A +client that can open `/ws` on loopback can spawn a shell, and a shell can read +`relay.json`, which holds the fleet *seed* — so it could already mint a cert for +any key it liked, valid on every machine. The endpoint collapses three steps +into one for the honest case and changes nothing for the dishonest one. For the +same reason it is HTTP on loopback and **must never acquire a wire-protocol +equivalent**: a relay-origin device cannot read `relay.json`, so a `wire.Enrol` +*would* be an escalation — admission to one machine becoming the power to +manufacture admission to every machine, for keys nobody has proved they hold. +It is idempotent by lookup, so a browser asks on every load rather than +remembering. + +**Why the browser may pin a fleet key here.** The rule above is about a +connection to a *peer*, and the danger it guards against is an intermediary or +an unknown party choosing the anchor every machine cert hangs from. On loopback +there is no such party: the socket goes to one process on this computer, the one +that owns the key and served the page. The pin *is* replaced here, unlike +above, and for a reason that is the mirror of the one given there — the cert +arrives with the key, from the process that minted both, so the pair is coherent +on arrival; and a loopback tab has no ceremony to be sent back to. + +**The directory route is transport and not trust.** It exists because the relay +answers `GET /directory` without an `Access-Control-Allow-Origin` header, so a +loopback tab's cross-origin fetch is discarded before it can be read — and +`readDirectory` reports every fault as "no machines", so the tab showed a fleet +of one. The daemon forwards the bytes unchanged and the browser verifies every +blob under the pinned fleet key exactly as it does when it reads the relay +directly. A directory the daemon had "checked" would be one the browser could be +tempted to trust on the daemon's say-so, which is the property the fleet key +exists to keep out of every intermediary's reach. + ## The fleet directory Auto-pair needs one piece of distribution: a device paired on machine A must From 4c5fc59280a2d6bb7b3c603fb76fd4b4cd2859d3 Mon Sep 17 00:00:00 2001 From: Karn Date: Mon, 10 Aug 2026 23:20:02 +0530 Subject: [PATCH 4/5] test(web): pin the read an ordinary loopback load does not make MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/fleet/fleet.test.ts | 21 +++++++++++++++++++++ web/src/fleet/fleet.ts | 16 ++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/web/src/fleet/fleet.test.ts b/web/src/fleet/fleet.test.ts index dbd49f1..8f40748 100644 --- a/web/src/fleet/fleet.test.ts +++ b/web/src/fleet/fleet.test.ts @@ -1705,6 +1705,27 @@ describe('a loopback tab that never ran a ceremony', () => { h.fleet.close() }) + it('costs no second read on the load that gains nothing', async () => { + // The ordinary second load, and what keeps enrolment from being a directory + // read per page load: the browser held both records all along, so the + // expansion built everything from storage on its own and an enrolment that + // gained nothing has nothing to add. + const loft = new FakeClient() + const expand = vi.fn(() => Promise.resolve([src('loft-9f9f', 'Loft', loft)])) + const local = new FakeClient() + const fleet = new FleetClient([src(LOCAL_MACHINE_ID, '', local)], expand, { + enrol: () => Promise.resolve(false), + }) + fleet.connect() + local.open() + local.emitWelcome(loopbackWelcome()) + + await vi.waitFor(() => expect(fleet.clientFor('loft-9f9f')).not.toBeNull()) + await flush() + expect(expand).toHaveBeenCalledTimes(1) + fleet.close() + }) + it('reads the relay’s own origin when nothing hands it a fetch', async () => { // The relay tab's half of the same seam, asserted where it is decided: with // no directoryFetch the expansion makes the plain cross-origin read it diff --git a/web/src/fleet/fleet.ts b/web/src/fleet/fleet.ts index 2d94571..99c462f 100644 --- a/web/src/fleet/fleet.ts +++ b/web/src/fleet/fleet.ts @@ -787,6 +787,22 @@ export class FleetClient { // enrolThisBrowser answers rather than throws; a caller's seam might not. return } + /* + * Nothing gained is the ordinary second load: the expansion read the same + * records out of storage by itself, and a rebuild would be a directory read + * for a set that cannot have changed. + * + * The one thing it also swallows is a development-only race. React mounts + * this twice, so two enrolments are briefly in flight, and if the first + * one's writes land between the second one's read and the expansion in + * between, the second is told "already there" about records the fleet has + * not seen. What that tab is left with is one machine until the next + * discovery tick, which is a minute — and `discover` exists for machines + * that arrive late anyway. Not worth a predicate here, and every predicate + * tried was wrong in the ordinary case: at the moment this resumes the + * expansion is usually still in flight, so "has it found anything yet" is a + * question with no answer. + */ if (!gained || epoch !== this.epoch || !this.running) return if (this.resupplied) return const origin = this.relayOrigin From 75f9a9501b4e623628356800a2ff43dcb0ab89d6 Mon Sep 17 00:00:00 2001 From: Karn Date: Mon, 10 Aug 2026 23:23:03 +0530 Subject: [PATCH 5/5] test(web): wait for the enrolment before asserting about it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/fleet/fleet.test.ts | 10 +++++++--- web/src/fleet/fleet.ts | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/web/src/fleet/fleet.test.ts b/web/src/fleet/fleet.test.ts index 8f40748..56fb2a1 100644 --- a/web/src/fleet/fleet.test.ts +++ b/web/src/fleet/fleet.test.ts @@ -1624,7 +1624,7 @@ describe('a loopback tab that never ran a ceremony', () => { h.local.emitWelcome(loopbackWelcome()) await vi.waitFor(() => expect(h.fleet.clientFor('loft-9f9f')).not.toBeNull()) const reads = directory.calls.length - expect(post.calls).toHaveLength(1) + await vi.waitFor(() => expect(post.calls).toHaveLength(1)) // A reconnect replays the welcome. Nothing is gained by it, so nothing is // asked and nothing is rebuilt. @@ -1637,8 +1637,8 @@ describe('a loopback tab that never ran a ceremony', () => { // thing, and gains nothing — so it does not re-read the directory either. h.fleet.close() h.fleet.connect() - await flush() - expect(post.calls).toHaveLength(2) + await vi.waitFor(() => expect(post.calls).toHaveLength(2)) + expect(directory.calls).toHaveLength(reads) h.fleet.close() }) @@ -1654,6 +1654,10 @@ describe('a loopback tab that never ran a ceremony', () => { h.fleet.connect() h.local.open() h.local.emitWelcome(loopbackWelcome()) + // Both halves have to have happened before any of this is a claim about + // anything: the enrolment refused, and the expansion the welcome triggered + // finished. Neither waits on the other, so each is waited for. + await vi.waitFor(() => expect(post.calls).toHaveLength(1)) await vi.waitFor(() => expect(h.fleet.gaps()).not.toBeNull()) expect(await loadPinnedFleetKey()).toBeNull() diff --git a/web/src/fleet/fleet.ts b/web/src/fleet/fleet.ts index 99c462f..25facec 100644 --- a/web/src/fleet/fleet.ts +++ b/web/src/fleet/fleet.ts @@ -22,6 +22,20 @@ * the relay at boot (it is the page's own origin, passed in by the caller; * nothing here reads `location`) and has no loopback at all. Both end in the * same place: one source per reachable machine, each id appearing once. + * + * The loopback tab has two more things to do before any of that means anything, + * and both arrive as seams rather than as knowledge this module goes looking + * for (see FleetOptions): it has to be enrolled as a device of the fleet, since + * it ran no ceremony and holds no certificate to present to a sibling machine, + * and it has to read the directory through its own daemon, because the relay + * answers a cross-origin fetch without the header a browser needs to hand it + * over. fleet/enrol.ts is both, and carries the argument for why a fleet key + * may be learned that way when it may not be learned over a connection. + * + * And the set is not fixed at page load. A machine that joins this afternoon + * appears in the directory this afternoon, so the expansion re-runs on an + * interval and on focus (`discover`) — additive only, because a read that comes + * back short is a relay having a bad minute rather than a machine leaving. */ import { daemonSocketUrl, FlueClient, type ConnStatus } from '@/client/client' import type { ErrorMsg, Preview, SessionInfo, Welcome } from '@/client/protocol'