Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
486b8d0
feat(relay): the fleet directory — blobs the Worker keeps and cannot …
karngyan Aug 10, 2026
d382196
feat(fleet): the daemon side of the directory — publish, verify, and …
karngyan Aug 10, 2026
754faa3
feat(pair): the pairing link carries the fleet public key
karngyan Aug 10, 2026
7726640
feat(web): the browser reads fleet certificates, and message A carrie…
karngyan Aug 10, 2026
5a26c33
feat(web): pin the fleet key at pairing, from the link and nowhere else
karngyan Aug 10, 2026
b9f7c56
feat(web): machines from the fleet directory, not only from ceremonies
karngyan Aug 10, 2026
ae71ebb
docs(relay): the fleet directory, what a full one costs, and the flag…
karngyan Aug 10, 2026
23bf47f
feat(relay): a way out of a full directory — DELETE /directory and `f…
karngyan Aug 10, 2026
358c367
docs(relay): say what a 507 actually does to a revocation
karngyan Aug 10, 2026
75c460b
fix(cloudflare): stop the migration tag read failing open, and repair…
karngyan Aug 10, 2026
342e0d3
feat(relay): warn before the fleet directory fills, on both status su…
karngyan Aug 10, 2026
d62846f
docs(spec): name the one exception to "availability is the relay's on…
karngyan Aug 10, 2026
ef99695
test(relaydeploy): make the wrangler.jsonc twin a test, not a promise
karngyan Aug 10, 2026
7f78603
fix(cloudflare): say something when the relay is from a newer flue
karngyan Aug 10, 2026
6643690
docs(web): say why the cert encoders disagree about iat, and pin that…
karngyan Aug 10, 2026
9eebd16
feat(relay): a tested socket cap, a conditional GET, and a rate-rule …
karngyan Aug 10, 2026
0a3f9ad
fix(daemon): let a loopback tab reach the relay its own daemon dials
karngyan Aug 10, 2026
1f60d13
feat(fleet): hand a device its certificate directly, at pairing and o…
karngyan Aug 10, 2026
50e5ff5
feat(fleet): take device certificates out of the relay's public direc…
karngyan Aug 10, 2026
9d624c8
fix(relay): stop the status surfaces counting devices the directory n…
karngyan Aug 10, 2026
f630150
docs(relay): refresh the `flue relay status` example for what the fle…
karngyan Aug 10, 2026
a4bd037
fix(fleet): stop the pairing ceremony publishing the device cert
karngyan Aug 10, 2026
13815e9
fix(relay): stop blaming an older flue for a device cert in the direc…
karngyan Aug 10, 2026
a1d8e8e
fix(fleet): let a re-supplied certificate unlock its machines in the …
karngyan Aug 10, 2026
c9dc0f6
docs(fleet): say that the certificate's second chance is a relay-tab …
karngyan Aug 10, 2026
4ce9f66
test(web): cover the certificate the pairing answer carries
karngyan Aug 10, 2026
60a2218
fix(daemon): resolve a welcome's fleet certificate by key, not by dev…
karngyan Aug 10, 2026
bc54326
docs(spec): rewrite the pruning refusal around the reason that is true
karngyan Aug 10, 2026
1df77dd
docs(fleet): retire the comments that still publish device certs, and…
karngyan Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion cmd/flue/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ const usageText = `flue — your terminal, as a browser tab
flue relay status show the configured relay
flue relay update redeploy this release's relay; secret and pairings kept
flue relay address URL repoint this machine at a custom domain on the same relay
flue relay reset empty the relay's fleet directory; the fleet republishes
flue open [path] spawn a session in path and open it in the browser
flue serve [--port N] [--open] run the daemon in the foreground
flue update download the newest release, swap this binary, restart the daemon
Expand Down Expand Up @@ -411,17 +412,63 @@ func startRelay(ctx context.Context, srv *daemon.Server, identity daemon.Identit
return false
}
}
cfg := relay.Config{URL: rc.URL, Secret: rc.Secret, Origin: rc.Origin, MachineID: rc.MachineID, FleetPub: fleetKey.Public()}
cfg := relay.Config{
URL: rc.URL,
Secret: rc.Secret,
Origin: rc.Origin,
MachineID: rc.MachineID,
MachineCert: rc.MachineCert,
FleetPub: fleetKey.Public(),
}
t, err := relay.New(cfg, srv, identity.Key, identity.Devices, logger)
if err != nil {
logger.Warn("relay not started", "err", err)
return false
}
// The directory leg, beside the hub leg and independent of it: one keeps
// this machine's browsers connected, the other keeps this machine's idea
// of who the fleet trusts up to date (spec/fleet-trust.md, "The fleet
// directory"). It is started here rather than inside the transport
// because neither needs the other — a daemon whose hub socket is down
// still has to hear a revocation, and one whose directory is down still
// serves every device paired to it.
//
// A directory this daemon cannot build is a warning and nothing more, for
// the reason every fault in this function is: flue's promise is a terminal
// in a browser tab, and the fleet is what makes that tab openable from the
// next machine along.
dir, err := relay.NewDirectory(cfg, srv, identity.Key, identity.Devices, logger)
if err != nil {
logger.Warn("fleet directory not started", "err", err)
} else {
// Installed before the goroutine, so a pairing or a revoke that
// happens while the first dial is still in flight is queued rather
// than lost.
srv.SetFleetPublisher(dir)
srv.SetDirectoryCounts(func() daemon.DirectoryCounts {
c := dir.Counts()
return daemon.DirectoryCounts(c)
})
go func() {
if err := dir.Run(ctx); err != nil {
logger.Warn("fleet directory stopped", "err", err)
}
}()
}
// The machine's identity rides every welcome alongside the status, so the
// UI can build /client/<id> URLs for this machine. It is configuration
// rather than socket state, which is why it is set here — once, by the
// process that read relay.json — and not by the transport's callbacks.
srv.SetRelayMachine(rc.MachineID, rc.MachineName)
// And the origin, for the Content-Security-Policy this daemon serves its own
// UI under. A loopback tab talks to the relay for everything that is not
// this machine — `wss://<relay>/client/<id>` per sibling machine, and
// `https://<relay>/directory` to learn which siblings exist — and neither is
// covered by `'self'`. Set from relay.json rather than from the transport's
// status because a document's policy is fixed when it is served, and a tab
// opened while the relay is still dialling still has to be allowed to reach
// it (daemon.LocalCSPFor).
srv.SetRelayOrigin(rc.Origin)
// Before the goroutine, never after it. The transport reports this itself
// the moment it starts dialling, and this only covers the window before it
// is scheduled — but a seed written *after* the goroutine started races the
Expand Down
53 changes: 35 additions & 18 deletions cmd/flue/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1297,16 +1297,27 @@ func TestStatusReportsAConfiguredRelayWithoutItsSecret(t *testing.T) {
func TestStartRelayDialsAConfiguredRelay(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())

var attempts atomic.Int64
var auth, path atomic.Value
// Both legs dial the same host, so what is recorded is per path: the hub
// leg at /daemon/<id> and the fleet directory at /directory. Recording a
// single "last dial" would be a race between two goroutines that both
// start here.
var mu sync.Mutex
dialed := map[string]string{} // path -> Authorization
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth.Store(r.Header.Get("Authorization"))
path.Store(r.URL.Path)
attempts.Add(1)
mu.Lock()
dialed[r.URL.Path] = r.Header.Get("Authorization")
mu.Unlock()
http.Error(w, "unauthorized", http.StatusUnauthorized)
}))
defer ts.Close()

sawDial := func(path string) (string, bool) {
mu.Lock()
defer mu.Unlock()
auth, ok := dialed[path]
return auth, ok
}

const secret = "s3cr3t-daemon-secret"
if err := config.SaveRelay(config.Relay{
URL: "ws" + strings.TrimPrefix(ts.URL, "http"),
Expand Down Expand Up @@ -1336,20 +1347,26 @@ func TestStartRelayDialsAConfiguredRelay(t *testing.T) {
defer cancel()
startRelay(ctx, srv, id)

deadline := time.Now().Add(3 * time.Second)
for attempts.Load() == 0 {
if time.Now().After(deadline) {
t.Fatal("the daemon never dialled the configured relay")
// One relay.json, two legs: the hub the browsers arrive on, and the fleet
// directory the revocations arrive on. The machine id from relay.json
// rides the hub path — it is how the Worker knows which machine's hub this
// socket is — and the directory has no id in its path at all, because one
// relay is one fleet.
for _, want := range []string{"/daemon/karns-macbook-pro-a1b2-0f9a12cd", "/directory"} {
deadline := time.Now().Add(3 * time.Second)
for {
auth, ok := sawDial(want)
if ok {
if auth != "Bearer "+secret {
t.Errorf("Authorization on %s = %q, want %q", want, auth, "Bearer "+secret)
}
break
}
if time.Now().After(deadline) {
t.Fatalf("the daemon never dialled %s", want)
}
time.Sleep(5 * time.Millisecond)
}
time.Sleep(5 * time.Millisecond)
}
if got, want := auth.Load().(string), "Bearer "+secret; got != want {
t.Errorf("Authorization = %q, want %q", got, want)
}
// The machine id from relay.json rides the dial path — it is how the
// Worker knows which machine's hub this socket is.
if got, want := path.Load().(string), "/daemon/karns-macbook-pro-a1b2-0f9a12cd"; got != want {
t.Errorf("dial path = %q, want %q", got, want)
}
}

Expand Down
Loading