Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<your-relay> --secret <...> # every other machine
flue relay setup # machine 1: paste a Cloudflare token
flue relay join wss://<your-relay> --secret <...> --fleet <...> # every other machine
```

That deploys a Worker **and** this web app into your own Cloudflare account,
Expand Down
80 changes: 73 additions & 7 deletions cmd/flue/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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, ", "))
Expand Down Expand Up @@ -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
}

Expand Down
37 changes: 31 additions & 6 deletions cmd/flue/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
}
Expand All @@ -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 ---
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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())
Expand Down
60 changes: 53 additions & 7 deletions cmd/flue/relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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/<id>) 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
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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 <url> --secret <secret> [--name <label>]"
const relayJoinUsage = "usage: flue relay join <url> --secret <secret> --fleet <fleet key> [--name <label>]"

// relayJoinDone mirrors relaySetupDone's restart note without the token line:
// join never saw a Cloudflare credential, so there is nothing to tell the user
Expand All @@ -360,6 +380,7 @@ func runRelayJoin(w io.Writer, args []string) error {
fs := flag.NewFlagSet("relay join", flag.ContinueOnError)
fs.SetOutput(io.Discard)
secret := fs.String("secret", "", "the relay's daemon secret, from flue relay setup")
fleetSeed := fs.String("fleet", "", "the fleet key, from flue relay setup")
name := fs.String("name", "", "a display name for this machine (defaults to the hostname)")
if err := fs.Parse(args[1:]); err != nil {
return fmt.Errorf("%w; %s", err, relayJoinUsage)
Expand All @@ -370,6 +391,27 @@ func runRelayJoin(w io.Writer, args []string) error {
if *secret == "" {
return errors.New("no --secret was given; " + relayJoinUsage)
}
// Required unconditionally, not "when the relay was set up with one".
// Join is a local command — it never talks to the relay, so it cannot
// ask how setup ran — and every setup from this flue on mints a fleet
// key, so the only line without --fleet is one printed by an older
// setup, whose relay the compatibility rules retire anyway
// (spec/fleet-trust.md): its ids stopped routing on the Worker this
// binary deploys, and the fix is re-running setup, which prints a line
// this check accepts. One rule, no state to consult, and a lost flag —
// the ordinary paste accident — is caught here rather than as a daemon
// that quietly cannot admit its fleet's devices.
if *fleetSeed == "" {
return errors.New("no --fleet was given; " + relayJoinUsage)
}
fleetKey, err := fleet.Parse(*fleetSeed)
if err != nil {
// The parse error says what the value failed to be (base64url, 32
// bytes) and never echoes it: the seed is the fleet's signing key,
// and a near-miss paste is close enough to the credential to keep
// out of the transcript.
return fmt.Errorf("--fleet: %w", err)
}
if utf8.RuneCountInString(*name) > machineNameMaxRunes {
return fmt.Errorf("--name is longer than %d characters", machineNameMaxRunes)
}
Expand All @@ -395,10 +437,14 @@ func runRelayJoin(w io.Writer, args []string) error {

// The same shape setup writes, derived the same way: bare wss:// URL, the
// https origin on the same host. SaveRelay is 0600 — the file holds the
// relay's whole credential.
// relay's whole credential, the fleet key now included. The seed is
// stored as the parsed key re-spells it, which for a value that passed
// fleet.Parse is the input verbatim; going through the round trip means
// the file can only ever hold a spelling Parse accepts.
if err := config.SaveRelay(config.Relay{
URL: "wss://" + host,
Secret: *secret,
FleetSeed: fleetKey.Seed(),
Origin: "https://" + host,
MachineID: machineID,
MachineName: machineName,
Expand Down
Loading