Everything is blocked on what a node presents to prove which node it is. This builds the other direction, which is not blocked: what a node believes. identity is the second of the seven contexts. It holds an Ed25519 signing key the control plane generates once, whose public half now travels in every enrolment token. A node believes a declaration because it carries a signature that key made -- pinning only the broker would make the control plane's authority transitive, and since the host applies whatever the link delivers, a compromised broker forging declarations is the whole machine. Establishing the key is idempotent, and it has to be: a second key generated by a restart is a mesh where every node holds the wrong public half, so every declaration is refused by every node with nothing visibly wrong. The guarantee is a partial unique index plus a read-back, not the check before the insert -- six processes racing to establish all agree on one key, and there is a test that runs them. Tokens are now one line of base64 carrying three of their four parts. The missing two are the broker's address and its certificate fingerprint, both step 5 of the bootstrap. The command prints the token and names what is missing rather than emitting something that looks usable. The second context also tests a claim this repository had made and never checked: that a context reaches only its own store. Two databases, two credentials, no setting that reaches both. Running migrate with one stops and names the grant it lacks -- verified, not asserted. Assembling a token needs a node record from one and a key from the other, and neither reads the other's store; the process holding both grants asks each for its part. 45 tests, none skipped. Fault injection found one test whose property is enforced somewhere other than where I injected -- idempotency comes from the database constraint, not from the early return, which is what the code comment already said.
308 lines
8.7 KiB
Go
308 lines
8.7 KiB
Go
// Command mesh-control is the control plane: everything that needs to know about more than one
|
|
// node (novox/hq ADR 0006).
|
|
//
|
|
// It runs as one process holding several contexts, each owning its own store. Today it holds one,
|
|
// `inventory`, and does one thing with it — brings its schema up to date, which is step 3 of the
|
|
// bootstrap in novox/hq 07-the-substrate and the step the first node cannot get past without.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-control/internal/identity"
|
|
"github.com/novox/mesh-control/internal/inventory"
|
|
"github.com/novox/mesh-control/internal/store"
|
|
"github.com/novox/mesh-control/internal/token"
|
|
)
|
|
|
|
// version is stamped at link time. Unset in a development build, and it says so rather than
|
|
// claiming a number.
|
|
var version = "development build"
|
|
|
|
// held is a context this process was granted, and the schema it carries.
|
|
//
|
|
// novox/hq ADR 0006 names seven. One is built. The list is short because the others do not exist
|
|
// yet, not because they are optional.
|
|
var held = []struct {
|
|
name string
|
|
migrations func() ([]store.Migration, error)
|
|
}{
|
|
{inventory.Name, inventory.Migrations},
|
|
{identity.Name, identity.Migrations},
|
|
}
|
|
|
|
func main() {
|
|
if err := run(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "mesh-control: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run() error {
|
|
args := os.Args[1:]
|
|
if len(args) == 0 {
|
|
usage()
|
|
return fmt.Errorf("no command given")
|
|
}
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
switch args[0] {
|
|
case "migrate":
|
|
return migrate(ctx)
|
|
case "node":
|
|
return nodeCommand(ctx, args[1:])
|
|
case "token":
|
|
return tokenCommand(ctx, args[1:])
|
|
case "identity":
|
|
return identityCommand(ctx, args[1:])
|
|
case "version":
|
|
fmt.Println(version)
|
|
return nil
|
|
case "help", "-h", "--help":
|
|
usage()
|
|
return nil
|
|
default:
|
|
usage()
|
|
return fmt.Errorf("%q is not a command", args[0])
|
|
}
|
|
}
|
|
|
|
func usage() {
|
|
fmt.Fprint(os.Stderr, `mesh-control — the control plane
|
|
|
|
migrate bring each context's schema up to date
|
|
node add <name> create a node record
|
|
node list the nodes this mesh knows about
|
|
token issue --node <name> a one-time right to join, for an existing record
|
|
token issue --new <name> create the record and issue for it
|
|
identity show this control plane's signing key
|
|
version what this binary is
|
|
|
|
Each context reaches its own store through its own credential (novox/hq ADR 0008), named
|
|
`+store.Variable("<context>")+`. This process holds:
|
|
|
|
`)
|
|
for _, c := range held {
|
|
fmt.Fprintf(os.Stderr, " %-12s database %-12s from %s\n",
|
|
c.name, store.Database(c.name), store.Variable(c.name))
|
|
}
|
|
fmt.Fprintln(os.Stderr)
|
|
}
|
|
|
|
// migrate brings every held context's schema up to date.
|
|
//
|
|
// Reported per context and per migration, because this runs during a bootstrap on a machine with
|
|
// nothing else on it — the output is the only account of what happened, and "migrated" is not one.
|
|
func migrate(ctx context.Context) error {
|
|
for _, c := range held {
|
|
migrations, err := c.migrations()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s, err := store.Open(ctx, c.name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer s.Close()
|
|
|
|
// The bootstrap raises PostgreSQL moments before this runs, and a container that is
|
|
// running is not a database that will answer — a distinction this project has already
|
|
// paid for once, when a crash-looping database reported itself as up between restarts.
|
|
if err := s.Ready(ctx, 60*time.Second); err != nil {
|
|
return err
|
|
}
|
|
|
|
done, err := s.Migrate(ctx, migrations)
|
|
for _, m := range done {
|
|
fmt.Printf("%s: applied %04d-%s\n", c.name, m.Number, m.Name)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(done) == 0 {
|
|
applied, err := s.AppliedMigrations(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%s: already up to date — %d migration(s)\n", c.name, len(applied))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// openInventory connects and waits, the way every command that touches it needs to.
|
|
func openInventory(ctx context.Context) (*inventory.Inventory, error) {
|
|
inv, err := inventory.Open(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := inv.Ready(ctx, 30*time.Second); err != nil {
|
|
inv.Close()
|
|
return nil, err
|
|
}
|
|
return inv, nil
|
|
}
|
|
|
|
func nodeCommand(ctx context.Context, args []string) error {
|
|
if len(args) == 0 {
|
|
return errors.New("node add <name>, or node list")
|
|
}
|
|
inv, err := openInventory(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer inv.Close()
|
|
|
|
switch args[0] {
|
|
case "add":
|
|
if len(args) != 2 {
|
|
return errors.New("node add <name>")
|
|
}
|
|
node, err := inv.AddNode(ctx, args[1])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("added %s (%s)\n", node.Name, node.ID)
|
|
return nil
|
|
|
|
case "list":
|
|
nodes, err := inv.Nodes(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(nodes) == 0 {
|
|
// Said rather than printed as nothing: an empty list and a failed read must never
|
|
// look the same, and this command answering "none" is only honest because getting
|
|
// here means the store answered.
|
|
fmt.Println("this mesh has no node records yet")
|
|
return nil
|
|
}
|
|
for _, n := range nodes {
|
|
fmt.Printf("%-20s %s added %s\n", n.Name, n.ID, n.Created.Format(time.RFC3339))
|
|
}
|
|
return nil
|
|
|
|
default:
|
|
return fmt.Errorf("node has no %q; it has add and list", args[0])
|
|
}
|
|
}
|
|
|
|
func tokenCommand(ctx context.Context, args []string) error {
|
|
if len(args) == 0 || args[0] != "issue" {
|
|
return errors.New("token issue --node <name>, or token issue --new <name>")
|
|
}
|
|
|
|
set := flag.NewFlagSet("token issue", flag.ContinueOnError)
|
|
existing := set.String("node", "", "issue for a node record that already exists")
|
|
fresh := set.String("new", "", "create the node record, then issue for it")
|
|
validFor := set.Duration("for", time.Hour, "how long the token may be used")
|
|
if err := set.Parse(args[1:]); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Exactly one, because the difference is what the token binds to. A command that guessed
|
|
// would sometimes create a second record for a machine that already has one.
|
|
if (*existing == "") == (*fresh == "") {
|
|
return errors.New("give exactly one of --node <name> or --new <name>: the first is a " +
|
|
"machine the mesh already has a record for, the second is one it has never seen")
|
|
}
|
|
|
|
inv, err := openInventory(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer inv.Close()
|
|
|
|
name := *existing
|
|
if *fresh != "" {
|
|
node, err := inv.AddNode(ctx, *fresh)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
name = node.Name
|
|
}
|
|
|
|
issued, err := inv.IssueToken(ctx, name, *validFor)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Assembled from two contexts by the process that holds both grants. Neither reads the
|
|
// other's store (novox/hq ADR 0008) — each is asked for its own part.
|
|
ident, err := openIdentity(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer ident.Close()
|
|
key, err := ident.Establish(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
made := token.Token{Signer: key.Public, Secret: issued.Secret}
|
|
encoded, err := made.Encode()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Printf("token for %s, usable once, until %s\n\n %s\n\n",
|
|
issued.Node.Name, issued.Expires.Format(time.RFC3339), encoded)
|
|
fmt.Println("This is the only time it is shown. What is stored is a hash of the secret.")
|
|
|
|
if missing := made.Missing(); len(missing) > 0 {
|
|
fmt.Printf("\nINCOMPLETE — this token cannot be used to join anything yet. Missing:\n")
|
|
for _, m := range missing {
|
|
fmt.Printf(" - %s\n", m)
|
|
}
|
|
fmt.Println("\nThe broker and its certificate are step 5 of the substrate bootstrap and " +
|
|
"do not exist yet\n(novox/hq 07-the-substrate). The signing key above is real.")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func openIdentity(ctx context.Context) (*identity.Identity, error) {
|
|
ident, err := identity.Open(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ident.Ready(ctx, 30*time.Second); err != nil {
|
|
ident.Close()
|
|
return nil, err
|
|
}
|
|
return ident, nil
|
|
}
|
|
|
|
func identityCommand(ctx context.Context, args []string) error {
|
|
if len(args) == 0 || args[0] != "show" {
|
|
return errors.New("identity show")
|
|
}
|
|
ident, err := openIdentity(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer ident.Close()
|
|
|
|
// Establish rather than read: a control plane asked for its identity before it has one should
|
|
// get one, not an error. Generating it is idempotent, so this is safe to run at any time.
|
|
key, err := ident.Establish(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("signing key %s\n", key.ID)
|
|
fmt.Printf("fingerprint %s\n", key.Fingerprint())
|
|
fmt.Printf("created %s\n", key.Created.Format(time.RFC3339))
|
|
fmt.Printf("\nThe public half of this travels in every enrolment token. A node believes a\n" +
|
|
"declaration because it carries a signature this key made (novox/hq ADR 0004).\n")
|
|
return nil
|
|
}
|