Node records, and the right to join once
The next step after the schema: inventory now holds node records and enrolment tokens, and mesh-control has the commands to work with them. A token is issued for a node record, which is where re-enrolment gets decided -- what an identity binds to is settled when the token is made, not when it is presented, so the machine presenting one does not need to know whether it is joining or returning. What the token guarantees, each with a test confirmed to fail when the behaviour is removed: the secret is 256 random bits, shown once and stored only as a hash; it works exactly once; it stops working when it expires; issuing again for a node invalidates the outstanding one, because two live tokens are two machines able to join as the same node. Redemption is a single statement that finds and spends together, so eight concurrent attempts on one secret produce exactly one winner rather than a race between a check and a write. Refusals are deliberately identical for unknown, spent and expired. Somebody guessing must not learn which guess was a real token that had merely aged out. SHA-256 rather than a password hash, and that is a choice not a shortcut: the secret is high-entropy random, so there is nothing to guess and a slow hash would buy nothing while making every redemption expensive. It stops before what a node receives in exchange. What a machine presents afterwards to prove it is that node is not decided anywhere, and a migration is the most expensive place here to guess. So a token carries one of the four things ADR 0004 requires. The command prints the secret and then says exactly that -- the broker's address, its certificate fingerprint and the control plane's signing identity do not exist yet. Better than emitting something that looks complete and silently cannot be used.
This commit is contained in:
+119
-2
@@ -8,6 +8,8 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -53,6 +55,10 @@ func run() error {
|
||||
switch args[0] {
|
||||
case "migrate":
|
||||
return migrate(ctx)
|
||||
case "node":
|
||||
return nodeCommand(ctx, args[1:])
|
||||
case "token":
|
||||
return tokenCommand(ctx, args[1:])
|
||||
case "version":
|
||||
fmt.Println(version)
|
||||
return nil
|
||||
@@ -68,8 +74,12 @@ func run() error {
|
||||
func usage() {
|
||||
fmt.Fprint(os.Stderr, `mesh-control — the control plane
|
||||
|
||||
migrate bring each context's schema up to date
|
||||
version what this binary is
|
||||
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
|
||||
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:
|
||||
@@ -123,3 +133,110 @@ func migrate(ctx context.Context) error {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
fmt.Printf("token for %s, usable once, until %s\n\n %s\n\n",
|
||||
issued.Node.Name, issued.Expires.Format(time.RFC3339), issued.Secret)
|
||||
fmt.Print("This is the only time that secret is shown; what is stored is a hash of it.\n\n")
|
||||
fmt.Print("INCOMPLETE. novox/hq ADR 0004 requires a token to carry four things, and this\n" +
|
||||
"carries one. Missing: the broker's address, the fingerprint of its certificate, and\n" +
|
||||
"the control plane's signing identity. None of the three exists yet, so this secret\n" +
|
||||
"cannot be used to join anything -- it is the half that could be built without them.\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user