diff --git a/cmd/flue/main.go b/cmd/flue/main.go index a07d806..bcca7c7 100644 --- a/cmd/flue/main.go +++ b/cmd/flue/main.go @@ -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 @@ -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/ 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:///client/` per sibling machine, and + // `https:///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 diff --git a/cmd/flue/main_test.go b/cmd/flue/main_test.go index 4d76f6f..9522986 100644 --- a/cmd/flue/main_test.go +++ b/cmd/flue/main_test.go @@ -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/ 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"), @@ -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) } } diff --git a/cmd/flue/relay.go b/cmd/flue/relay.go index 7a3afa2..e5ddb73 100644 --- a/cmd/flue/relay.go +++ b/cmd/flue/relay.go @@ -3,12 +3,15 @@ package main import ( "bufio" "context" + "crypto/ed25519" "crypto/rand" + "encoding/json" "errors" "flag" "fmt" "io" "io/fs" + "net/http" "net/url" "os" "strconv" @@ -18,9 +21,11 @@ import ( "github.com/karnstack/flue/internal/cloudflare" "github.com/karnstack/flue/internal/config" + "github.com/karnstack/flue/internal/crypto" "github.com/karnstack/flue/internal/daemon" "github.com/karnstack/flue/internal/fleet" "github.com/karnstack/flue/internal/relaydeploy" + "github.com/karnstack/flue/internal/transport/relay" relaybundle "github.com/karnstack/flue/relay" "github.com/karnstack/flue/web" ) @@ -69,7 +74,7 @@ const ( // number, and EOF alone is not enough of a guarantee to rely on. const accountPromptAttempts = 3 -const relayUsage = "usage: flue relay " +const relayUsage = "usage: flue relay " func cmdRelay(args []string) error { if len(args) == 0 { @@ -88,6 +93,8 @@ func cmdRelay(args []string) error { return runRelayUpdate(os.Stdout, os.Stdin, &cloudflare.Client{}, args[1:]) case "address": return runRelayAddress(os.Stdout, args[1:]) + case "reset": + return runRelayReset(os.Stdout, os.Stdin, args[1:]) default: return fmt.Errorf("unknown relay subcommand %q; %s", args[0], relayUsage) } @@ -268,6 +275,9 @@ func runRelaySetup(w io.Writer, r io.Reader, api *cloudflare.Client, args []stri AssetHeaders: relayAssetHeaders, Version: deployStamp(), OnStep: func(line string) { fmt.Fprintf(w, " ✓ %s\n", line) }, + // No tick: a note is something the deploy could not fix, and the same + // two-space indent every soft failure in this file already wears. + OnNote: func(line string) { fmt.Fprintf(w, " %s\n", line) }, }) if err != nil { return err @@ -298,6 +308,17 @@ func runRelaySetup(w io.Writer, r io.Reader, api *cloudflare.Client, args []stri machineID := config.MintMachineID(hostname, secret, rand.Reader) machineName := truncateRunes(hostname, machineNameMaxRunes) + // The machine's own certificate, signed under the fleet key just minted: + // what the daemon publishes to the relay's fleet directory so every + // browser in the fleet can reach this machine without pairing to it + // (spec/fleet-trust.md, "The fleet directory"). Not fatal — a relay whose + // machines cannot be discovered still carries every device paired + // directly to them — and the line says which half is missing. + machineCert, err := mintMachineCert(fleetKey, machineID, machineName) + if err != nil { + fmt.Fprintf(w, " could not mint this machine's fleet certificate (%v); other devices will not discover this machine\n", err) + } + // Last, deliberately. relay.json is what makes the daemon dial, and every // step above can fail; writing it earlier would leave a daemon dialling a // relay that was never finished. Re-running setup is the fix for anything @@ -316,6 +337,7 @@ func runRelaySetup(w io.Writer, r io.Reader, api *cloudflare.Client, args []stri Origin: origin, MachineID: machineID, MachineName: machineName, + MachineCert: machineCert, Worker: worker, }); err != nil { return fmt.Errorf("save the relay configuration: %w", err) @@ -353,6 +375,47 @@ func runRelaySetup(w io.Writer, r io.Reader, api *cloudflare.Client, args []stri // a machine list rendering as a list. const machineNameMaxRunes = 64 +// mintMachineCert signs this machine's fleet machine certificate: the id it +// holds on the relay, its display name, and the Noise static key every device +// that reaches it must pin (spec/fleet-trust.md, Certificates). +// +// It is minted here — in the three commands that write relay.json — rather +// than by the daemon, and the reason is the directory's shape rather than +// convenience. The relay stores blobs by the hash of their own bytes, so a +// cert re-minted at each start would carry a fresh `iat`, land under a fresh +// key, and spend one of the directory's 512 entries every time the daemon +// restarted. Minting it exactly where the facts it asserts are decided means +// one blob, for the life of this machine's place on this relay. +// +// It reads (and, on a machine that has never served, creates) the daemon's +// static key, which is the same key `flue serve` would load a moment later: +// the cert has to name the key devices will actually meet, and a cert naming a +// key that did not exist yet would be a browser pinning nothing. +// +// A failure is returned, not swallowed. Every caller treats it as "this +// machine joins without a machine cert" and says so — the honest half of a +// half-configured relay, exactly as `flue relay setup` already treats a fleet +// key it cannot mint. +func mintMachineCert(key fleet.Key, machineID, machineName string) ([]byte, error) { + if !key.Valid() { + return nil, fleet.ErrNoKey + } + dir, err := config.Dir() + if err != nil { + return nil, fmt.Errorf("locate the config directory: %w", err) + } + static, err := crypto.LoadOrCreateStaticKey(dir) + if err != nil { + return nil, fmt.Errorf("load the daemon static key: %w", err) + } + return key.Sign(fleet.MachineCert{ + ID: machineID, + Name: machineName, + Noise: static.Public, + IAT: time.Now().Unix(), + }) +} + const relayJoinUsage = "usage: flue relay join --secret --fleet [--name