Three faults the mesh's own logs showed this morning. A handler that outlives the acknowledgement window was handed its message again while it was still working: acting on a merge builds modules, minutes against a thirty-second window, so one merge ran the whole catalogue five times over. The transport now says the work is in progress while it runs, which is where the window belongs. Everything the mesh hands out — a token, a membership, a person's credential — took its address from the enrolment setting, which on a mesh that has moved still names the broker it moved from: the first person issued after the move was handed the retired broker's port. There is one bus, and its address is the one the control plane is connected to. And `operator issue` documented an argument order its parser refused.
308 lines
11 KiB
Go
308 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/novox/mesh-controller/internal/broker"
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
"github.com/novox/mesh-controller/internal/secrets"
|
|
)
|
|
|
|
// operatorCommand is the mesh's one holder of secrets that is not a machine.
|
|
//
|
|
// **Every secret a module holds for itself is sealed to the node that uses it, and a node whose key
|
|
// is gone takes its secrets with it** — the store's superuser and the broker's administrator among
|
|
// them. novox/hq ADR 0085 (amended) gives them a second recipient: a person, with a sealing key
|
|
// whose private half is made where the operator is and never enters the mesh. Making the key and
|
|
// telling the mesh about it are two commands, on purpose: the first needs no mesh at all and runs
|
|
// wherever the operator keeps things; the second gives the mesh the public half and nothing else.
|
|
// The controller's own container is a scratch image with no writable path, which is the right
|
|
// shape for a program that must hold no key — so the private half could not be written there
|
|
// even by mistake.
|
|
//
|
|
// operator key make [--out <file>] make a keypair: private half to the file, public half printed
|
|
// operator key set <public> tell the mesh which key to seal to
|
|
// operator key show the public key, its fingerprint, and what it can recover
|
|
const operatorUsage = "operator key make [--out <file>] | operator key set <public> [--replace] | " +
|
|
"operator key show | operator issue <name> --invokes <tool,tool|*> | operator revoke <name> | " +
|
|
"operator list"
|
|
|
|
func operatorCommand(ctx context.Context, args []string) error {
|
|
if len(args) == 0 {
|
|
return errors.New(operatorUsage)
|
|
}
|
|
// The people who may reach the mesh's tools (design 25 §7). Beside the operator's key because
|
|
// both answer "who, other than a machine, may do something here" — and a person reading this
|
|
// command's usage is asking exactly that.
|
|
switch args[0] {
|
|
case "issue":
|
|
return personIssue(ctx, args[1:])
|
|
case "revoke":
|
|
return personRevoke(ctx, args[1:])
|
|
case "list":
|
|
return personList(ctx)
|
|
}
|
|
if len(args) < 2 || args[0] != "key" {
|
|
return errors.New(operatorUsage)
|
|
}
|
|
switch args[1] {
|
|
case "make":
|
|
return operatorKeyMake(args[2:])
|
|
case "set":
|
|
return operatorKeySet(ctx, args[2:])
|
|
case "show":
|
|
return operatorKeyShow(ctx)
|
|
default:
|
|
return errors.New(operatorUsage)
|
|
}
|
|
}
|
|
|
|
// operatorKeyMake needs no mesh: it is the operator's, run wherever the key will live.
|
|
func operatorKeyMake(args []string) error {
|
|
set := flag.NewFlagSet("operator key make", flag.ContinueOnError)
|
|
out := set.String("out", "operator.key",
|
|
"where to write the private key (0600); keep it off the mesh, and keep it")
|
|
if err := set.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
public, private, err := secrets.Keypair()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Create-exclusive: a key somebody may still need is never overwritten, and there is no window
|
|
// between checking and writing in which one could appear.
|
|
if err := writeNew(*out, []byte(private+"\n")); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("operator key %s\n", secrets.Fingerprint(public))
|
|
fmt.Printf(" private half written to %s (0600) — keep it off the mesh, and keep it\n", *out)
|
|
fmt.Printf(" public half, to give the mesh with `operator key set`:\n")
|
|
fmt.Printf("public %s\n", public)
|
|
return nil
|
|
}
|
|
|
|
func operatorKeySet(ctx context.Context, args []string) error {
|
|
rest, flags := split(args)
|
|
set := flag.NewFlagSet("operator key set", flag.ContinueOnError)
|
|
replace := set.Bool("replace", false,
|
|
"replace an existing operator key — secrets sealed to the old one stay sealed to it")
|
|
if err := set.Parse(flags); err != nil {
|
|
return err
|
|
}
|
|
if len(rest) != 1 {
|
|
return errors.New(operatorUsage)
|
|
}
|
|
public := strings.TrimSpace(rest[0])
|
|
if _, err := secrets.Seal(public, []byte("probe")); err != nil {
|
|
return fmt.Errorf("that is not a public sealing key: %w", err)
|
|
}
|
|
|
|
open, err := openStores(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer open.Close()
|
|
inv := open.inventory
|
|
|
|
if current, err := inv.OperatorKey(ctx); err != nil {
|
|
return err
|
|
} else if current != "" && current != public && !*replace {
|
|
return fmt.Errorf(
|
|
"the mesh already has an operator key (%s). Pass --replace to change it — "+
|
|
"secrets sealed to the current key stay sealed to it until each is issued again",
|
|
secrets.Fingerprint(current))
|
|
}
|
|
orphaned, err := inv.SetOperatorKey(ctx, public)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("operator key %s\n", secrets.Fingerprint(public))
|
|
fmt.Printf(" the mesh holds the public half only and cannot open what it seals to it;\n")
|
|
fmt.Printf(" from now on every secret a module holds for itself is sealed to it as well.\n")
|
|
fmt.Printf(" Secrets made before this cannot be — each is recoverable once issued again\n")
|
|
if orphaned > 0 {
|
|
fmt.Printf(" %d secret(s) are sealed to the previous key and stay so until issued again\n", orphaned)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func operatorKeyShow(ctx context.Context) error {
|
|
open, err := openStores(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer open.Close()
|
|
inv := open.inventory
|
|
key, err := inv.OperatorKey(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if key == "" {
|
|
fmt.Println("the mesh has no operator key; `operator key make` then `operator key set` gives it one")
|
|
return nil
|
|
}
|
|
kept, earlier, unrecoverable, err := inv.KeptForOperator(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("operator key %s\n %s\n", secrets.Fingerprint(key), key)
|
|
fmt.Printf(" %d secret(s) recoverable with it\n", len(kept))
|
|
if len(earlier) > 0 {
|
|
fmt.Printf(" %d secret(s) sealed to an earlier operator key — recoverable with that key only, until issued again:\n", len(earlier))
|
|
for _, k := range earlier {
|
|
fmt.Printf(" %s %s %s (%s)\n", k.Node, k.Module, k.Name, secrets.Fingerprint(k.Key))
|
|
}
|
|
}
|
|
if len(unrecoverable) > 0 {
|
|
fmt.Printf(" %d secret(s) not recoverable — made before the mesh had an operator key:\n", len(unrecoverable))
|
|
for _, k := range unrecoverable {
|
|
fmt.Printf(" %s %s %s\n", k.Node, k.Module, k.Name)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// readPrivateKey is the operator's key from the file `operator key make` wrote.
|
|
func readPrivateKey(path string) (string, error) {
|
|
if path == "" {
|
|
return "", errors.New("--key <file> names the operator's private key, written by `operator key make`")
|
|
}
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return strings.TrimSpace(string(raw)), nil
|
|
}
|
|
|
|
// personIssue gives somebody a credential for the mesh's tools, and prints it once.
|
|
//
|
|
// **Printed, not stored.** The mesh keeps a hash and nothing else, so this is the only moment the
|
|
// credential exists anywhere but on the workstation that will use it — the same contract a token has,
|
|
// and for the same reason: a credential recoverable from the mesh's store has the store's blast
|
|
// radius.
|
|
func personIssue(ctx context.Context, args []string) error {
|
|
set := flag.NewFlagSet("operator issue", flag.ContinueOnError)
|
|
invokes := set.String("invokes", "", "the tools this person may call, comma-separated, or * for every one")
|
|
// Flags on either side of the name, because the usage this command prints puts them after it —
|
|
// and the standard parser stops at the first thing that is not a flag, so the order the command
|
|
// documents was the one order it refused (2026-09-28).
|
|
positionals, err := parseAround(set, args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(positionals) != 1 {
|
|
return errors.New("operator issue <name> --invokes <tool,tool|*>")
|
|
}
|
|
name := positionals[0]
|
|
if *invokes == "" {
|
|
return errors.New(
|
|
"say what this person may call: --invokes mesh-catalog.catalog_tools,gitea.repo_create, " +
|
|
"or --invokes '*' for an administrator")
|
|
}
|
|
var tools []string
|
|
for _, t := range strings.Split(*invokes, ",") {
|
|
if t = strings.TrimSpace(t); t != "" {
|
|
tools = append(tools, t)
|
|
}
|
|
}
|
|
|
|
open, err := openStores(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer open.Close()
|
|
inv := open.inventory
|
|
|
|
if err := inv.RecordPerson(ctx, inventory.Person{Name: name, Invokes: tools}); err != nil {
|
|
return err
|
|
}
|
|
// Refused here rather than at the next composition, where it would stop the whole file being
|
|
// written for everybody. A name that cannot be part of a subject is one the server would read as
|
|
// a wider permission than anybody granted.
|
|
if _, err := broker.PermissionsFor(broker.Principal{
|
|
Kind: broker.KindPerson, Module: name, Invokes: tools, PasswordHash: "x",
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
user := broker.Principal{Kind: broker.KindPerson, Module: name}.Username()
|
|
password, err := inv.MintBusPassword(ctx, inventory.BusUser{Username: user, Kind: inventory.BusPerson})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
where, err := broker.FromEnvironment()
|
|
if err != nil && !errors.Is(err, broker.ErrNotConfigured) {
|
|
return err
|
|
}
|
|
held, err := json.Marshal(struct {
|
|
URL string `json:"url"`
|
|
Fingerprint string `json:"fingerprint,omitempty"`
|
|
User string `json:"user"`
|
|
Password string `json:"password"`
|
|
Person string `json:"person"`
|
|
Invokes []string `json:"invokes"`
|
|
}{
|
|
URL: "nats://" + where.Address, Fingerprint: where.Fingerprint,
|
|
User: user, Password: password, Person: name, Invokes: tools,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Printf("issued %s, who may call %s\n", name, strings.Join(tools, ", "))
|
|
fmt.Println(" this is the only time the credential is printed; the mesh keeps a hash")
|
|
fmt.Println(" it works once the bus has been told, which is the next push to the machine holding mesh-broker")
|
|
fmt.Println()
|
|
fmt.Println(string(held))
|
|
return nil
|
|
}
|
|
|
|
func personRevoke(ctx context.Context, args []string) error {
|
|
if len(args) != 1 {
|
|
return errors.New("operator revoke <name>")
|
|
}
|
|
open, err := openStores(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer open.Close()
|
|
|
|
if err := open.inventory.ForgetPerson(ctx, args[0]); err != nil {
|
|
return err
|
|
}
|
|
// **Revoked at the next composition, not now.** The bus's users are a file, so a credential stops
|
|
// working when the file no longer names it. Said plainly, because "revoked" that still works for
|
|
// another minute is worth knowing about.
|
|
fmt.Printf("%s is forgotten, and their credential stops working at the next composition — "+
|
|
"push the machine holding mesh-broker to make it so\n", args[0])
|
|
return nil
|
|
}
|
|
|
|
func personList(ctx context.Context) error {
|
|
open, err := openStores(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer open.Close()
|
|
|
|
people, err := open.inventory.People(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(people) == 0 {
|
|
fmt.Println("nobody but machines reaches this mesh")
|
|
return nil
|
|
}
|
|
for _, p := range people {
|
|
fmt.Printf("%-20s %s\n", p.Name, strings.Join(p.Invokes, ", "))
|
|
}
|
|
return nil
|
|
}
|