diff --git a/README.md b/README.md index e2774d5..8f072e1 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,8 @@ The daemon binds loopback and nothing else, so reaching it from elsewhere is opt-in and takes one command: ```sh -flue relay setup # machine 1: paste a Cloudflare token -flue relay join wss:// --secret <...> # every other machine +flue relay setup # machine 1: paste a Cloudflare token +flue relay join wss:// --secret <...> --fleet <...> # every other machine ``` That deploys a Worker **and** this web app into your own Cloudflare account, diff --git a/cmd/flue/main.go b/cmd/flue/main.go index e54a1ce..a07d806 100644 --- a/cmd/flue/main.go +++ b/cmd/flue/main.go @@ -25,6 +25,7 @@ import ( "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/service" "github.com/karnstack/flue/internal/session" "github.com/karnstack/flue/internal/transport/local" @@ -106,7 +107,7 @@ const usageText = `flue — your terminal, as a browser tab flue disable remove the login service flue status daemon, login service, and session diagnostics flue relay setup deploy a relay to your own Cloudflare account - flue relay join URL --secret S point this machine at an existing relay + flue relay join URL --secret S --fleet K point this machine at an existing relay 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 @@ -323,7 +324,27 @@ func loadIdentity() (daemon.Identity, error) { if err != nil { return daemon.Identity{}, fmt.Errorf("load the daemon static key: %w", err) } - return daemon.Identity{Key: key, Devices: crypto.NewDeviceStore(dir)}, nil + id := daemon.Identity{Key: key, Devices: crypto.NewDeviceStore(dir)} + + // The fleet key rides relay.json (spec/fleet-trust.md), so it is read + // here beside the other identity material rather than by the relay + // startup: pairing mints device certs and revocation mints revocations + // whether or not the transport ever comes up. An unreadable or absent + // relay.json leaves the identity fleet-less — startRelay reports the + // unreadable case, and a daemon without a fleet key pairs exactly as it + // always did. A relay.json that parses but carries a seed this daemon + // cannot use is fatal, by the same reasoning as the static key above: a + // daemon that started anyway would sign nothing and verify nothing while + // looking perfectly healthy, and a corrupted credential file is a thing + // to say out loud, not to route around. + if rc, ok, err := config.LoadRelay(); err == nil && ok && rc.FleetSeed != "" { + fk, err := fleet.Parse(rc.FleetSeed) + if err != nil { + return daemon.Identity{}, fmt.Errorf("relay.json carries a fleet seed this daemon cannot use: %w", err) + } + id.Fleet = fk + } + return id, nil } // startRelay dials the configured relay, if there is one, and keeps it dialled @@ -359,7 +380,38 @@ 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} + // The public half only: signing stays with the daemon (pairing, + // revocation), while the transport verifies the certs strangers present. + // + // Parsed from the file this function just read rather than taken from + // identity.Fleet, which is the same value only at boot. The path where + // they differ is the one that matters: a relay deployed from the Remote + // screen writes a brand-new relay.json — fresh secret, fresh fleet key — + // and then calls this in a process whose boot-time identity has no fleet + // key at all, or an older one. Reading it from the identity there would + // hand relay.New a nil public key, have it refuse the relay the user had + // just deployed, and leave one log line behind. + // + // What still waits for a restart is the *signing* half: Identity is fixed + // at construction, so a daemon that deployed a relay from the screen + // verifies its fleet's certs from this moment and mints none of its own + // until it comes back. relayUIService.Provision says so in its steps; a + // nil Public() here, meanwhile, means relay.json carries no fleet key at + // all, which relay.New refuses by name (spec/fleet-trust.md keeps no + // compatibility with pre-fleet files, deliberately). + // + // A seed that does not parse costs remote access and nothing else, like + // every other fault here. loadIdentity is the one that refuses a bad seed + // outright, and it has already run by the time this does. + var fleetKey fleet.Key + if rc.FleetSeed != "" { + fleetKey, err = fleet.Parse(rc.FleetSeed) + if err != nil { + logger.Warn("relay not started", "err", err) + return false + } + } + cfg := relay.Config{URL: rc.URL, Secret: rc.Secret, Origin: rc.Origin, MachineID: rc.MachineID, FleetPub: fleetKey.Public()} t, err := relay.New(cfg, srv, identity.Key, identity.Devices, logger) if err != nil { logger.Warn("relay not started", "err", err) @@ -1296,10 +1348,12 @@ func relayLine() string { } // A file the daemon will not dial must not be reported as "configured". - // This is the report somebody reads to find out why remote access does not - // work, and the faults are the ones relay.New refuses: a missing field, or - // — only possible by hand — both kinds of credential at once, which cannot - // be resolved into one dial. The problems are named; no value ever is. + // This is the report somebody reads to find out why remote access does + // not work, and the faults listed are exactly the ones relay.New refuses + // — a field it requires and this file does not carry. Keeping the two + // lists in step is a standing obligation: a fault relay.New grows and + // this one does not is a daemon that silently stops dialling while every + // report says it is fine. The problems are named; no value ever is. if problems := relayProblems(rc); len(problems) > 0 { return fmt.Sprintf("relay: configured, but not usable (%s): the daemon will not dial it", strings.Join(problems, ", ")) @@ -1328,6 +1382,18 @@ func relayProblems(rc config.Relay) []string { // the fix, and mints one. problems = append(problems, "no machine id") } + if rc.FleetSeed == "" { + // A relay.json from before the fleet key existed. relay.New refuses + // it by name (spec/fleet-trust.md keeps no compatibility with + // pre-fleet files, deliberately), so this daemon dials nothing at all + // — which is the whole reason it is listed here: the upgrade that + // produces this state is silent otherwise, one stderr warning at + // startup and a status line that used to say everything was fine. + // The fix is a join line carrying `--fleet`: re-run the one printed + // by `flue relay setup` on a machine that has it, or re-run setup + // itself, which mints a fresh fleet key with the fresh secret. + problems = append(problems, "no fleet key") + } return problems } diff --git a/cmd/flue/main_test.go b/cmd/flue/main_test.go index 0aeef48..4d76f6f 100644 --- a/cmd/flue/main_test.go +++ b/cmd/flue/main_test.go @@ -21,6 +21,7 @@ import ( "github.com/karnstack/flue/internal/config" "github.com/karnstack/flue/internal/daemon" + "github.com/karnstack/flue/internal/fleet" "github.com/karnstack/flue/internal/session" "github.com/karnstack/flue/internal/transport/local" ) @@ -1261,6 +1262,7 @@ func TestStatusReportsAConfiguredRelayWithoutItsSecret(t *testing.T) { Origin: "https://flue-relay.example", MachineID: "karns-macbook-pro-a1b2-0f9a12cd", MachineName: "Karn's MacBook Pro", + FleetSeed: testFleetSeed, }); err != nil { t.Fatalf("SaveRelay: %v", err) } @@ -1276,6 +1278,12 @@ func TestStatusReportsAConfiguredRelayWithoutItsSecret(t *testing.T) { if strings.Contains(out, secret) { t.Fatalf("status printed the daemon secret:\n%s", out) } + // The fleet key is the other credential relay.json holds, and the newer + // one: it signs every cert the fleet trusts, so a status output pasted + // into a bug report must not carry it either. + if strings.Contains(out, testFleetSeed) { + t.Fatalf("status printed the fleet key:\n%s", out) + } } // --- the relay leg of serve --- @@ -1306,17 +1314,27 @@ func TestStartRelayDialsAConfiguredRelay(t *testing.T) { Origin: "https://r.example", MachineID: "karns-macbook-pro-a1b2-0f9a12cd", MachineName: "Karn's MacBook Pro", + FleetSeed: testFleetSeed, }); err != nil { t.Fatalf("SaveRelay: %v", err) } + // The identity serve would have built: the same file's seed, parsed. The + // relay leg refuses to start without a fleet key, so a zero Identity here + // would test nothing but that refusal. + fk, err := fleet.Parse(testFleetSeed) + if err != nil { + t.Fatalf("fleet.Parse: %v", err) + } + id := daemon.Identity{Fleet: fk} + srv := daemon.New(session.NewRegistry(time.Now), local.NewAuth("0123456789abcdef", 0), - uiHandler(), version, daemon.Identity{}) + uiHandler(), version, id) t.Cleanup(srv.Shutdown) ctx, cancel := context.WithCancel(context.Background()) defer cancel() - startRelay(ctx, srv, daemon.Identity{}) + startRelay(ctx, srv, id) deadline := time.Now().Add(3 * time.Second) for attempts.Load() == 0 { @@ -1396,13 +1414,20 @@ func TestStatusReportsAnIncompleteRelayConfig(t *testing.T) { relay config.Relay want string }{ - {"no url", config.Relay{Secret: secret, Origin: "https://r.example", MachineID: "m-0001"}, "no url"}, - {"no secret", config.Relay{URL: "wss://r.example", Origin: "https://r.example", MachineID: "m-0001"}, "no secret"}, - {"no origin", config.Relay{URL: "wss://r.example", Secret: secret, MachineID: "m-0001"}, "no origin"}, + {"no url", config.Relay{Secret: secret, FleetSeed: testFleetSeed, Origin: "https://r.example", MachineID: "m-0001"}, "no url"}, + {"no secret", config.Relay{URL: "wss://r.example", FleetSeed: testFleetSeed, Origin: "https://r.example", MachineID: "m-0001"}, "no secret"}, + {"no origin", config.Relay{URL: "wss://r.example", Secret: secret, FleetSeed: testFleetSeed, MachineID: "m-0001"}, "no origin"}, // A relay.json from before machines had ids, or one hand-edited into // that shape: the daemon will not dial it (relay.New refuses), so the // status line has to say why rather than call it configured. - {"no machine id", config.Relay{URL: "wss://r.example", Secret: secret, Origin: "https://r.example"}, "no machine id"}, + {"no machine id", config.Relay{URL: "wss://r.example", Secret: secret, FleetSeed: testFleetSeed, Origin: "https://r.example"}, "no machine id"}, + // The one an upgrade produces on its own: a relay.json written before + // the fleet key existed is complete by every older rule and refused by + // relay.New (spec/fleet-trust.md keeps no compatibility with those). + // Until this case existed, that machine lost remote access while + // `flue status`, `flue relay status` and /api/relay/info all called it + // configured and fine. + {"no fleet key", config.Relay{URL: "wss://r.example", Secret: secret, Origin: "https://r.example", MachineID: "m-0001"}, "no fleet key"}, } { t.Run(tc.name, func(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) diff --git a/cmd/flue/relay.go b/cmd/flue/relay.go index 99e0d0d..7a3afa2 100644 --- a/cmd/flue/relay.go +++ b/cmd/flue/relay.go @@ -19,6 +19,7 @@ import ( "github.com/karnstack/flue/internal/cloudflare" "github.com/karnstack/flue/internal/config" "github.com/karnstack/flue/internal/daemon" + "github.com/karnstack/flue/internal/fleet" "github.com/karnstack/flue/internal/relaydeploy" relaybundle "github.com/karnstack/flue/relay" "github.com/karnstack/flue/web" @@ -273,6 +274,20 @@ func runRelaySetup(w io.Writer, r io.Reader, api *cloudflare.Client, args []stri } origin := "https://" + host + // The fleet key, minted beside the fresh secret and — unlike it — never + // sent anywhere: no binding, no secret upload, no log line + // (spec/fleet-trust.md). It travels only in relay.json below and in the + // join line printed at the end, and it is what signs the device certs + // every machine on this relay honours. Fresh on every setup for the same + // reason the secret is: setup is the recovery path, and rotating the + // fleet key is what un-trusts every cert a compromised machine could + // have signed. + fleetKey, err := fleet.Mint(rand.Reader) + if err != nil { + return err + } + fmt.Fprintln(w, " ✓ fleet key minted (stays on your machines; Cloudflare never sees it)") + // This machine's identity on the relay: the id is the slot it dials // (/daemon/) and the name is its human label. Minted fresh on every // run like the secret — setup is the recovery path, and a stale id would @@ -297,6 +312,7 @@ func runRelaySetup(w io.Writer, r io.Reader, api *cloudflare.Client, args []stri if err := config.SaveRelay(config.Relay{ URL: "wss://" + host, Secret: secret, + FleetSeed: fleetKey.Seed(), Origin: origin, MachineID: machineID, MachineName: machineName, @@ -318,11 +334,15 @@ func runRelaySetup(w io.Writer, r io.Reader, api *cloudflare.Client, args []stri } } - // The one line another machine needs, exactly as it should be run there. - // It carries the secret — that is the point: the relay is shared by - // machines that share it, and this is the deliberate hand-off, printed - // once at the moment the user is wiring their fleet up. - fmt.Fprintf(w, "\nto add another machine, run this on it:\n\n flue relay join wss://%s --secret %s\n", host, secret) + // The one line another machine needs, exactly as it should be run there, + // spelled by joinCommand so this print and the Remote screen's copy can + // never drift. It carries the secret and now the fleet key — that is the + // point: the relay is shared by machines that share them, and this is + // the deliberate hand-off, printed once at the moment the user is wiring + // their fleet up. Its weight changed when the fleet key came aboard: + // leaking this line used to buy disruption, and now it buys the fleet — + // docs/RELAY.md says so where it teaches the line. + fmt.Fprintf(w, "\nto add another machine, run this on it:\n\n %s\n", joinCommand(host, secret, fleetKey.Seed())) fmt.Fprint(w, relaySetupDone) return nil @@ -333,7 +353,7 @@ func runRelaySetup(w io.Writer, r io.Reader, api *cloudflare.Client, args []stri // a machine list rendering as a list. const machineNameMaxRunes = 64 -const relayJoinUsage = "usage: flue relay join --secret [--name