diff --git a/cmd/mesh-controller/rollout.go b/cmd/mesh-controller/rollout.go index 2ff5600..d2e9f2f 100644 --- a/cmd/mesh-controller/rollout.go +++ b/cmd/mesh-controller/rollout.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "os" "strings" "time" @@ -33,7 +34,7 @@ import ( // ability to change things, not the services its modules are serving — measured on 2026-09-27, when // a seat emptied mid-change and the control plane looped for two hours while every service stayed up. -const rolloutUsage = "rollout check | rollout mint [--again] | rollout --confirm" +const rolloutUsage = "rollout check | rollout mint [--again] | rollout hand | rollout --confirm" func rolloutCommand(ctx context.Context, args []string) error { switch { @@ -41,6 +42,8 @@ func rolloutCommand(ctx context.Context, args []string) error { return rolloutCheck(ctx) case len(args) == 1 && args[0] == "mint": return rolloutMint(ctx, false) + case len(args) == 2 && args[0] == "hand": + return rolloutHand(ctx, args[1]) case len(args) == 2 && args[0] == "mint" && args[1] == "--again": // Every credential minted afresh, whether or not one exists — for a mint that was wrong // before anything was pushed. Afterwards nothing that received the old one still works, @@ -387,3 +390,62 @@ func providesBus(m catalogue.Manifest) bool { } return false } + +// rolloutHand mints a machine its credential for the new bus afresh and prints its membership +// once, for an operator to carry by hand — the rescue for a machine that cannot be reached over +// any bus: rotated while it still held the old password, or reachable only by ssh. The plaintext +// exists on this terminal and then only where it is written; the store keeps the hash, and the +// sealed copy in the machine's declaration is replaced too, so the next push says the same. +func rolloutHand(ctx context.Context, node string) error { + open, err := openStores(ctx) + if err != nil { + return err + } + defer open.Close() + inv := open.inventory + + known, err := broker.FromEnvironment() + if err != nil { + return fmt.Errorf("the bus's certificate is not known to this process: %w", err) + } + busAddress, _, err := broker.OnNATS() + if err != nil { + return err + } + if busAddress == "" { + return errors.New("this control plane is not on the new bus, so there is no membership to hand out") + } + _, _, bare := broker.CredentialIn(busAddress) + if _, after, has := strings.Cut(bare, "://"); has { + bare = after + } + if _, err := inv.NodeByName(ctx, node); err != nil { + return err + } + p := broker.Principal{Kind: broker.KindNode, Node: node} + password, err := inv.MintBusPassword(ctx, inventory.BusUser{Username: p.Username(), Kind: inventory.BusNode, Node: node}) + if err != nil { + return err + } + membership, _ := json.Marshal(map[string]string{ + "broker": bare, "fingerprint": known.Fingerprint, "password": password, "transport": "nats", + }) + key, err := inv.SealingKeyOf(ctx, node) + if err != nil { + return err + } + sealed, err := secrets.Seal(key, membership) + if err != nil { + return err + } + if err := inv.PutBusMembership(ctx, node, sealed); err != nil { + return err + } + // The one line of output is the membership itself, so it can be piped to the machine without + // being read on the way. Everything else goes to stderr. + fmt.Fprintf(os.Stderr, "%s's credential is minted afresh. Write this to %s on it and restart its host; "+ + "then push the machine running the bus so the user list carries the new hash.\n", + node, catalogue.BusMembershipPath) + fmt.Println(string(membership)) + return nil +}