Merge pull request 'The enrolment seat and node-login rotation contract: a joining machine's first tunnel, add-node and remove-node, two logins per node (issue 774)' (#157) from feat/l3-enrol into main
This commit was merged in pull request #157.
This commit is contained in:
@@ -3,6 +3,7 @@ package buscompose
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -419,6 +420,156 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
// 774/S39 (ADR 0583 §1): while a node rotates, both its logins are users with the node's permissions and its one
|
||||||
|
// inbox, each calling in its own name; every node reads its own node-logins key alone; with no rotation the list
|
||||||
|
// is the one above (AC2: the controller's golden lists, unchanged).
|
||||||
|
t.Run("a node rotating has two logins of one node", func(t *testing.T) {
|
||||||
|
vault := Seat{Name: VaultSeat, Serves: []string{"rotate-node", "node-account-confirm", "sign", "own"}}
|
||||||
|
since := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC)
|
||||||
|
users, err := Users(Records{Nodes: []string{"ace", "g14"},
|
||||||
|
Assigned: map[string][]Declared{"ace": {{Module: "mesh-vault", Holds: []Seat{vault}}}},
|
||||||
|
NodeLogins: map[string]delivery.NodeLogin{
|
||||||
|
"g14": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: delivery.RotationBegun, Since: since},
|
||||||
|
// A node that rotated before and rotates no more logs in as its second login alone.
|
||||||
|
"ace": {Node: "ace", Login: "node.ace--b", Since: since}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
perms := map[string]Permissions{}
|
||||||
|
for _, u := range users {
|
||||||
|
if u.Kind == KindNode {
|
||||||
|
p, err := PermissionsFor(u)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
perms[u.Username()] = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(perms) != 3 || perms["node.g14"].Publish == nil || perms["node.g14--b"].Publish == nil || perms["node.ace--b"].Publish == nil {
|
||||||
|
t.Fatalf("the node users are %v", slices.Sorted(maps.Keys(perms)))
|
||||||
|
}
|
||||||
|
// Equal, but for the caller each names in its calls (the bus's proof of which login called, ADR 0583 §3).
|
||||||
|
own := func(p Permissions, login string) Permissions {
|
||||||
|
swap := func(in []string) []string {
|
||||||
|
out := make([]string, len(in))
|
||||||
|
for i, s := range in {
|
||||||
|
out[i] = strings.ReplaceAll(s, CallerToken(login), "<caller>")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return Permissions{Publish: swap(p.Publish), PublishDeny: swap(p.PublishDeny), Subscribe: swap(p.Subscribe),
|
||||||
|
AllowResponses: p.AllowResponses}
|
||||||
|
}
|
||||||
|
if a, b := own(perms["node.g14"], "node.g14"), own(perms["node.g14--b"], "node.g14--b"); !reflect.DeepEqual(a, b) {
|
||||||
|
t.Errorf("the two logins' permissions differ:\n%v\n%v", a, b)
|
||||||
|
}
|
||||||
|
for login, p := range perms {
|
||||||
|
node, _ := delivery.LoginNode(login)
|
||||||
|
if !slices.Contains(p.Subscribe, "_INBOX.node."+node+".>") || slices.Contains(p.Subscribe, "_INBOX."+login+".>") && login != "node."+node {
|
||||||
|
t.Errorf("%s's inbox: %v", login, p.Subscribe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// AC3: g14 reads node-logins key g14 and not ace, and writes neither; it confirms in its own login's name.
|
||||||
|
get := func(key string) string {
|
||||||
|
return "$JS.API.DIRECT.GET.KV_" + delivery.NodeLoginsBucket + ".$KV." + delivery.NodeLoginsBucket + "." + key
|
||||||
|
}
|
||||||
|
confirm := func(login string) string {
|
||||||
|
return "mesh.seat.mesh-vault.call.node-account-confirm." + CallerToken(login)
|
||||||
|
}
|
||||||
|
for subject, want := range map[string]bool{get("g14"): true, get("ace"): false, "$KV." + delivery.NodeLoginsBucket + ".g14": false,
|
||||||
|
confirm("node.g14--b"): true, confirm("node.g14"): false} {
|
||||||
|
if MayPublish(perms["node.g14--b"], subject) != want {
|
||||||
|
t.Errorf("node.g14--b may publish %s: %v, want %v", subject, !want, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if plain, _ := PermissionsFor(Principal{Kind: KindNode, Node: "g14"}); MayPublish(plain, get("g14")) {
|
||||||
|
t.Error("a node reads node-logins while the vault does not rotate")
|
||||||
|
}
|
||||||
|
// And nothing but the grants part names node-logins among its reads.
|
||||||
|
if !ReaderMay(GrantsModule, delivery.NodeLoginsRead) || ReaderMay("shop", delivery.NodeLoginsRead) {
|
||||||
|
t.Error("node-logins' named readers")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// 774/S40/AC1 (ADR 0587 §4, §6): from the lease write an enrolling node's user may publish the redeem call in its
|
||||||
|
// own name and nothing else, and subscribe its own inbox alone; before it, the controller's subject beside.
|
||||||
|
t.Run("an enrolling node redeems at the seat alone", func(t *testing.T) {
|
||||||
|
holder := Seat{Name: catalogue.EnrolmentSeat, Serves: []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}}
|
||||||
|
for _, handed := range []bool{true, false} {
|
||||||
|
users, err := Users(Records{Nodes: []string{"ace"}, Enrolling: []string{"lab"}, LeaseHandedAway: handed,
|
||||||
|
Assigned: map[string][]Declared{"ace": {{Module: "mesh-enrolment", Holds: []Seat{holder}}}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var lab *Principal
|
||||||
|
for i := range users {
|
||||||
|
if users[i].Username() == "enrol.lab" {
|
||||||
|
lab = &users[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lab == nil {
|
||||||
|
t.Fatal("no user enrol.lab")
|
||||||
|
}
|
||||||
|
perms, err := PermissionsFor(*lab)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []string{"mesh.seat.mesh-enrolment.call.redeem.enrol~lab"}
|
||||||
|
if !handed {
|
||||||
|
want = []string{"mesh.control.enrol", "mesh.seat.mesh-enrolment.call.redeem.enrol~lab"}
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(perms.Publish, want) || !reflect.DeepEqual(perms.Subscribe, []string{"_INBOX.enrol.lab.>"}) ||
|
||||||
|
perms.AllowResponses {
|
||||||
|
t.Errorf("handed %v: enrol.lab publishes %v, subscribes %v, answers %v", handed, perms.Publish, perms.Subscribe,
|
||||||
|
perms.AllowResponses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No holder serving redeem and the lease handed: the user may enrol nowhere, refused by name.
|
||||||
|
if _, err := PermissionsFor(Principal{Kind: KindEnrolment, Node: "lab", LeaseHandedAway: true}); err == nil {
|
||||||
|
t.Error("an enrolment user that may enrol nowhere was composed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// 774/S41/AC1 (ADR 0587 §1-§3, ADR 0561 §5): mesh-enrolment writes its bucket (with per-key expiry) and calls the
|
||||||
|
// vault's enrolment-token, node-account and sign in its own name; no other module or runtime may call those two,
|
||||||
|
// however wide its grant; and its bucket is read by the grants part alone besides it.
|
||||||
|
t.Run("mesh-enrolment alone asks the vault to enrol", func(t *testing.T) {
|
||||||
|
vault := Seat{Name: VaultSeat, Serves: []string{"sign", "enrolment-token", "node-account"}}
|
||||||
|
holder := Seat{Name: catalogue.EnrolmentSeat, Serves: []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}}
|
||||||
|
calls := []string{"seat:mesh-vault.enrolment-token", "seat:mesh-vault.node-account", "seat:mesh-vault.sign"}
|
||||||
|
users, err := Users(Records{Nodes: []string{"ace"}, Assigned: map[string][]Declared{"ace": {
|
||||||
|
{Module: "mesh-vault", Holds: []Seat{vault}},
|
||||||
|
// One process with a bus account of its own (ADR 0587 §1): never carried by the tool runner.
|
||||||
|
{Module: "mesh-enrolment", RunsAs: "mesh-enrolment", Holds: []Seat{holder}, Invokes: calls,
|
||||||
|
State: []Bucket{{Module: "mesh-enrolment", Name: "enrolments", KeyTTL: true}}},
|
||||||
|
{Module: "shop", Invokes: calls[:2]},
|
||||||
|
{Module: RuntimeModule}, {Module: "agent-tools", Invokes: []string{"*"}}}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
call := func(verb, user string) string { return "mesh.seat.mesh-vault.call." + verb + "." + CallerToken(user) }
|
||||||
|
for _, u := range users {
|
||||||
|
perms, err := PermissionsFor(u)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
name := u.Username()
|
||||||
|
for _, verb := range []string{"enrolment-token", "node-account"} {
|
||||||
|
enrols := name == "ace.mesh-enrolment"
|
||||||
|
if MayPublish(perms, call(verb, name)) != enrols || (!enrols && MayPublish(perms, "mesh.seat.mesh-vault.tool."+verb)) {
|
||||||
|
t.Errorf("%s may call %s: %v", name, verb, !enrols)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "ace.mesh-enrolment" && (!MayPublish(perms, call("sign", name)) ||
|
||||||
|
!MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".node.lab") || !MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".address.10.0.0.7")) {
|
||||||
|
t.Errorf("mesh-enrolment does not sign or write its bucket: %v", perms.Publish)
|
||||||
|
}
|
||||||
|
if name != "ace.mesh-enrolment" && MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".node.lab") {
|
||||||
|
t.Errorf("%s writes the enrolments", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ReaderMay(GrantsModule, delivery.EnrolmentsRead) || ReaderMay("shop", delivery.EnrolmentsRead) {
|
||||||
|
t.Error("the enrolments' named readers")
|
||||||
|
}
|
||||||
|
})
|
||||||
t.Run("the controller's golden server configuration", func(t *testing.T) {
|
t.Run("the controller's golden server configuration", func(t *testing.T) {
|
||||||
seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}}
|
seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}}
|
||||||
got, err := Compose(
|
got, err := Compose(
|
||||||
|
|||||||
+104
-2
@@ -22,6 +22,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -253,6 +254,26 @@ type Principal struct {
|
|||||||
// stream and name. Created only, never updated or deleted.
|
// stream and name. Created only, never updated or deleted.
|
||||||
CreatesConsumers []ModuleConsumer
|
CreatesConsumers []ModuleConsumer
|
||||||
|
|
||||||
|
// Login is a node principal's login when it is not `node.<n>`: its second login, `node.<n>--b`, composed beside or
|
||||||
|
// in place of the first as the vault's node-logins says (novox/hq ADR 0583 §1, issue 774). Each login is granted
|
||||||
|
// the node's permissions and the node's inbox `_INBOX.node.<n>`; what it calls, it calls in its own name, so the
|
||||||
|
// vault tells the login that confirms a rotation from the one it replaces (§3).
|
||||||
|
Login string
|
||||||
|
// RotatesItsLogin is a machine principal whose node-engine reads its own node's key of the vault's node-logins,
|
||||||
|
// and no other key, and confirms a switch with node-account-confirm in its own name (novox/hq ADR 0583 §1 insight,
|
||||||
|
// §3). Set while the vault's holder serves rotate-node.
|
||||||
|
RotatesItsLogin bool
|
||||||
|
|
||||||
|
// Redeems is an enrolment principal that may call the seat mesh-enrolment's redeem in its own name, and nothing else
|
||||||
|
// of it (novox/hq ADR 0587 §4): set while the seat's holder serves redeem. LeaseHandedAway drops the controller's
|
||||||
|
// enrolment subject from it, which is served no more from the lease write (§6).
|
||||||
|
Redeems bool
|
||||||
|
LeaseHandedAway bool
|
||||||
|
|
||||||
|
// VaultEnrols says the vault's holder serves an enrolment verb (VerbsTheEnrolmentAsksTheVault): every principal but
|
||||||
|
// the module mesh-enrolment and its processes is then denied them, however wide its grant (ADR 0587 §1).
|
||||||
|
VaultEnrols bool
|
||||||
|
|
||||||
// PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal
|
// PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal
|
||||||
// and never appears here: this file is written to a node's disk and read by a server, and a
|
// and never appears here: this file is written to a node's disk and read by a server, and a
|
||||||
// secret that can be read from a configuration file is a secret with a wider blast radius
|
// secret that can be read from a configuration file is a secret with a wider blast radius
|
||||||
@@ -303,6 +324,22 @@ func ControllerOnly() []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VerbsTheEnrolmentAsksTheVault are the vault's verbs the seat mesh-enrolment's holder alone calls (novox/hq ADR 0587
|
||||||
|
// §1, §3; ADR 0561 §5): a token and its hash made and dropped, a joining node's account minted and retired.
|
||||||
|
var VerbsTheEnrolmentAsksTheVault = []SeatVerb{{Seat: VaultSeat, Verb: delivery.EnrolmentTokenVerb},
|
||||||
|
{Seat: VaultSeat, Verb: delivery.NodeAccountVerb}}
|
||||||
|
|
||||||
|
// EnrolmentOnly are the subjects the holder of mesh-enrolment alone may publish, however wide another's grant: the
|
||||||
|
// vault's two enrolment verbs, by the tool's and the caller-named shape (Principal.VaultEnrols denies them).
|
||||||
|
func EnrolmentOnly() []string {
|
||||||
|
var out []string
|
||||||
|
for _, v := range VerbsTheEnrolmentAsksTheVault {
|
||||||
|
out = append(out, "mesh.seat."+v.Seat+".tool."+v.Verb, "mesh.seat."+v.Seat+".tool."+v.Verb+".>",
|
||||||
|
"mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+".>")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// MayPublish says whether permissions let a principal publish one subject: an allow covers it and no deny does.
|
// MayPublish says whether permissions let a principal publish one subject: an allow covers it and no deny does.
|
||||||
func MayPublish(perms Permissions, subject string) bool {
|
func MayPublish(perms Permissions, subject string) bool {
|
||||||
for _, d := range perms.PublishDeny {
|
for _, d := range perms.PublishDeny {
|
||||||
@@ -380,6 +417,33 @@ var VaultOwn = SeatVerb{Seat: "mesh-vault", Verb: "own"}
|
|||||||
// ADR 0550 §2); granted as Principal.AsksTheVaultToSign says.
|
// ADR 0550 §2); granted as Principal.AsksTheVaultToSign says.
|
||||||
var VaultSign = SeatVerb{Seat: "mesh-vault", Verb: delivery.SignVerb}
|
var VaultSign = SeatVerb{Seat: "mesh-vault", Verb: delivery.SignVerb}
|
||||||
|
|
||||||
|
// VaultRotateNode is the vault's verb that begins a node's account rotation; while its holder serves it, every node
|
||||||
|
// principal reads its own node's key of node-logins (novox/hq ADR 0583 §1, §2). VaultNodeAccountConfirm is the verb a
|
||||||
|
// node-engine confirms a switch with, in its new login's name (§3).
|
||||||
|
var (
|
||||||
|
VaultRotateNode = SeatVerb{Seat: "mesh-vault", Verb: delivery.RotateNodeVerb}
|
||||||
|
VaultNodeAccountConfirm = SeatVerb{Seat: "mesh-vault", Verb: delivery.NodeAccountConfirmVerb}
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnrolmentRedeem is the seat mesh-enrolment's verb a joining node-engine calls as enrol.<node> (novox/hq ADR 0587 §4,
|
||||||
|
// §6).
|
||||||
|
var EnrolmentRedeem = SeatVerb{Seat: catalogue.EnrolmentSeat, Verb: catalogue.EnrolmentVerbRedeem}
|
||||||
|
|
||||||
|
// EnrolmentRedeemCall is the one subject an enrolment user may publish once it enrols through the seat: redeem, in its
|
||||||
|
// own name.
|
||||||
|
func EnrolmentRedeemCall(p Principal) string {
|
||||||
|
return "mesh.seat." + EnrolmentRedeem.Seat + "." + CallKind + "." + EnrolmentRedeem.Verb + "." + CallerToken(p.Username())
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeLoginsReads is what a node's engine is granted of the vault's node-logins: its own node's key, read and watched,
|
||||||
|
// and no other (novox/hq ADR 0583 §1 insight).
|
||||||
|
func NodeLoginsReads(node string) []string {
|
||||||
|
if !safeSubject.MatchString(node) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return readGrants(delivery.NodeLoginsBucket, node)
|
||||||
|
}
|
||||||
|
|
||||||
// VerbsTheControllerActsOnAWarrant are the other seat verbs the controller calls when the operator's warrant
|
// VerbsTheControllerActsOnAWarrant are the other seat verbs the controller calls when the operator's warrant
|
||||||
// chooses them (novox/hq ADR 0259): a machine's service restarted, and a walk started or stopped through the
|
// chooses them (novox/hq ADR 0259): a machine's service restarted, and a walk started or stopped through the
|
||||||
// controller's own `plans`, a machine's held builds sent by its own `push` and the builds held after a failed release
|
// controller's own `plans`, a machine's held builds sent by its own `push` and the builds held after a failed release
|
||||||
@@ -528,6 +592,9 @@ func (p Principal) Username() string {
|
|||||||
// never the module's own name alone, which is a different account with a different secret.
|
// never the module's own name alone, which is a different account with a different secret.
|
||||||
return p.Node + "." + p.Module + "." + p.Process
|
return p.Node + "." + p.Module + "." + p.Process
|
||||||
case KindNode:
|
case KindNode:
|
||||||
|
if p.Login != "" {
|
||||||
|
return p.Login
|
||||||
|
}
|
||||||
return "node." + p.Node
|
return "node." + p.Node
|
||||||
case KindController:
|
case KindController:
|
||||||
return "controller"
|
return "controller"
|
||||||
@@ -587,7 +654,14 @@ func AskAccessActSubject(node string) string { return "mesh.node." + node + ".as
|
|||||||
// inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25
|
// inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25
|
||||||
// §4): with one account, inbox privacy is the permission list or it is nothing, so each user's
|
// §4): with one account, inbox privacy is the permission list or it is nothing, so each user's
|
||||||
// inbox is derived from its own identity and its permissions name that prefix and no other.
|
// inbox is derived from its own identity and its permissions name that prefix and no other.
|
||||||
func (p Principal) inbox() string { return "_INBOX." + p.Username() + ".>" }
|
//
|
||||||
|
// A node's two logins share the node's inbox (novox/hq ADR 0583 §1): its answers reach it whichever login it dials as.
|
||||||
|
func (p Principal) inbox() string {
|
||||||
|
if p.Kind == KindNode {
|
||||||
|
return "_INBOX.node." + p.Node + ".>"
|
||||||
|
}
|
||||||
|
return "_INBOX." + p.Username() + ".>"
|
||||||
|
}
|
||||||
|
|
||||||
// Permissions is what a principal may publish and subscribe, and whether it may answer.
|
// Permissions is what a principal may publish and subscribe, and whether it may answer.
|
||||||
type Permissions struct {
|
type Permissions struct {
|
||||||
@@ -889,7 +963,20 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
"enrolment: a token is issued for a node record, and that record's name is " +
|
"enrolment: a token is issued for a node record, and that record's name is " +
|
||||||
"the token's id")
|
"the token's id")
|
||||||
}
|
}
|
||||||
pub = []string{"mesh.control.enrol"}
|
// **From the lease write a node enrols through the seat mesh-enrolment alone** (novox/hq ADR 0587 §4, §6): its
|
||||||
|
// user calls redeem in its own name, while the seat's holder serves it, and the controller's enrolment subject is
|
||||||
|
// kept only while the controller holds the lease.
|
||||||
|
pub = nil
|
||||||
|
if !p.LeaseHandedAway {
|
||||||
|
pub = append(pub, "mesh.control.enrol")
|
||||||
|
}
|
||||||
|
if p.Redeems {
|
||||||
|
pub = append(pub, EnrolmentRedeemCall(p))
|
||||||
|
}
|
||||||
|
if len(pub) == 0 {
|
||||||
|
return Permissions{}, fmt.Errorf("the enrolment user of %s may enrol nowhere: the lease is handed away and "+
|
||||||
|
"no holder of %s serves %s", p.Node, EnrolmentRedeem.Seat, EnrolmentRedeem.Verb)
|
||||||
|
}
|
||||||
sub = []string{p.inbox()}
|
sub = []string{p.inbox()}
|
||||||
|
|
||||||
case KindNode:
|
case KindNode:
|
||||||
@@ -1003,6 +1090,13 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
pub = append(pub, readGrants(delivery.SignersBucket, "")...)
|
pub = append(pub, readGrants(delivery.SignersBucket, "")...)
|
||||||
pub = append(pub, readGrants(LeaseBucket, leaseKey)...)
|
pub = append(pub, readGrants(LeaseBucket, leaseKey)...)
|
||||||
}
|
}
|
||||||
|
// And, while the vault rotates node accounts, its own node's key of node-logins, read and watched, and no other
|
||||||
|
// key (novox/hq ADR 0583 §1 insight): the login it dials as; and the confirmation of a switch in its own name (§3).
|
||||||
|
if p.RotatesItsLogin {
|
||||||
|
pub = append(pub, NodeLoginsReads(p.Node)...)
|
||||||
|
pub = append(pub, "mesh.seat."+VaultNodeAccountConfirm.Seat+"."+CallKind+"."+VaultNodeAccountConfirm.Verb+"."+
|
||||||
|
CallerToken(p.Username()))
|
||||||
|
}
|
||||||
// And its node seat, node-engine, and its asks to the operator in its own name (novox/hq ADR 0563 §1, §3).
|
// And its node seat, node-engine, and its asks to the operator in its own name (novox/hq ADR 0563 §1, §3).
|
||||||
ep, es := engineGrants(p)
|
ep, es := engineGrants(p)
|
||||||
pub = append(pub, ep...)
|
pub = append(pub, ep...)
|
||||||
@@ -1440,6 +1534,14 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// And the vault's enrolment verbs to every principal but mesh-enrolment's (novox/hq ADR 0587 §1).
|
||||||
|
if p.VaultEnrols && !((p.Kind == KindModule || p.Kind == KindProcess) && p.Module == delivery.EnrolmentModule) {
|
||||||
|
for _, only := range EnrolmentOnly() {
|
||||||
|
if slices.ContainsFunc(pub, func(a string) bool { return SubjectsOverlap(a, only) }) {
|
||||||
|
deny = append(deny, only)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return Permissions{
|
return Permissions{
|
||||||
Publish: pub,
|
Publish: pub,
|
||||||
PublishDeny: deny,
|
PublishDeny: deny,
|
||||||
|
|||||||
@@ -104,7 +104,12 @@ var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}, GrantsA
|
|||||||
ControllerConditions: {health.KeeperModule}, ControllerHandActs: {health.HealersModule, handacts.Module}, HealersWould: {},
|
ControllerConditions: {health.KeeperModule}, ControllerHandActs: {health.HealersModule, handacts.Module}, HealersWould: {},
|
||||||
// ADR 0577 clause 1: the node-engine's data items are read by disk-load alone, each node's its own node's keys
|
// ADR 0577 clause 1: the node-engine's data items are read by disk-load alone, each node's its own node's keys
|
||||||
// (NodeKeyedReads); the engine announces nothing of its own, so its readers are named here.
|
// (NodeKeyedReads); the engine announces nothing of its own, so its readers are named here.
|
||||||
delivery.EngineDataRead: {health.DiskLoadModule}}
|
delivery.EngineDataRead: {health.DiskLoadModule},
|
||||||
|
// ADR 0583 §1 insight: every node's login is read by the grants part alone, each engine its own node's key through its
|
||||||
|
// machine principal (NodeLoginsReads), never through `reads`.
|
||||||
|
delivery.NodeLoginsRead: {GrantsModule},
|
||||||
|
// ADR 0587 §2: the open enrolments are read by their holder and, as a named reader, by the grants part.
|
||||||
|
delivery.EnrolmentsRead: {GrantsModule}}
|
||||||
|
|
||||||
// ReaderMay says whether module may read the state read (`<owner>.<name>`) by the named readers: always, for a state
|
// ReaderMay says whether module may read the state read (`<owner>.<name>`) by the named readers: always, for a state
|
||||||
// that names none.
|
// that names none.
|
||||||
|
|||||||
+51
-7
@@ -2,10 +2,12 @@ package buscompose
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Every user the composed file should contain, derived from what the mesh knows.
|
// Every user the composed file should contain, derived from what the mesh knows.
|
||||||
@@ -129,7 +131,8 @@ type Records struct {
|
|||||||
Assigned map[string][]Declared
|
Assigned map[string][]Declared
|
||||||
// Enrolling is every node with a live token — one enrolment user each, because the inbox an
|
// Enrolling is every node with a live token — one enrolment user each, because the inbox an
|
||||||
// answer goes to is scoped to the token and a shared one is one machine reading another's
|
// answer goes to is scoped to the token and a shared one is one machine reading another's
|
||||||
// sealed credentials (design 25 §6).
|
// sealed credentials (design 25 §6). The grants part fills it from the seat mesh-enrolment's keys at live or
|
||||||
|
// redeeming (novox/hq ADR 0587 §4), the controller from its store.
|
||||||
Enrolling []string
|
Enrolling []string
|
||||||
// People is each person's name against the tools they may invoke, `*` for an administrator.
|
// People is each person's name against the tools they may invoke, `*` for an administrator.
|
||||||
People map[string][]string
|
People map[string][]string
|
||||||
@@ -145,6 +148,14 @@ type Records struct {
|
|||||||
// grants part as a placed module's are; it adds no principal, since each node's engine speaks as the node's account.
|
// grants part as a placed module's are; it adds no principal, since each node's engine speaks as the node's account.
|
||||||
// Nil where its manifest was not read.
|
// Nil where its manifest was not read.
|
||||||
Engine *Declared
|
Engine *Declared
|
||||||
|
// LeaseHandedAway says the controller's lease names a holder that is no controller (ADR 0582 §3), as the grants part
|
||||||
|
// reads it: an enrolment user then enrols through the seat mesh-enrolment alone (novox/hq ADR 0587 §6). The
|
||||||
|
// controller's composer never sets it. It is not LeaseCut: the cut is composed before the holder is written, so the
|
||||||
|
// grants part may write it, and outlasts the holder at a hand-back; the two are true together only in between.
|
||||||
|
LeaseHandedAway bool
|
||||||
|
// NodeLogins is each node's key of the vault's node-logins (novox/hq ADR 0583 §1): the login in use and the one
|
||||||
|
// rotating, each composed as a user of the node's permissions. A node absent logs in as `node.<n>` alone.
|
||||||
|
NodeLogins map[string]delivery.NodeLogin
|
||||||
// Announced is each module's last announcement of the states it keeps (novox/hq ADR 0495, issue 1790), as the
|
// Announced is each module's last announcement of the states it keeps (novox/hq ADR 0495, issue 1790), as the
|
||||||
// controller keeps it; a module absent has announced nothing yet. Nil where none was read.
|
// controller keeps it; a module absent has announced nothing yet. Nil where none was read.
|
||||||
Announced map[string]announce.States
|
Announced map[string]announce.States
|
||||||
@@ -180,10 +191,14 @@ func Users(r Records) ([]Principal, error) {
|
|||||||
}
|
}
|
||||||
checks = append(checks, d.Checks...)
|
checks = append(checks, d.Checks...)
|
||||||
}
|
}
|
||||||
out = append(out, Principal{Kind: KindNode, Node: node, WitnessesController: witness, Checks: checks,
|
// **One user per login the node has** (novox/hq ADR 0583 §1): the one in use, and while it rotates the other too,
|
||||||
MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey),
|
// each with the node's permissions.
|
||||||
AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign), Uses: asksSeatOf(r),
|
for _, login := range nodeLoginsOf(r, node) {
|
||||||
FollowsGrantsUserList: bus, ServedReads: servedOn(served, node)})
|
out = append(out, Principal{Kind: KindNode, Node: node, Login: login, WitnessesController: witness, Checks: checks,
|
||||||
|
MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey),
|
||||||
|
AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign), Uses: asksSeatOf(r),
|
||||||
|
FollowsGrantsUserList: bus, ServedReads: servedOn(served, node), RotatesItsLogin: servesVerb(r, VaultRotateNode)})
|
||||||
|
}
|
||||||
// **And the machine's provisioner client, on an account of its own** (novox/hq ADR 0349 clause 11): one
|
// **And the machine's provisioner client, on an account of its own** (novox/hq ADR 0349 clause 11): one
|
||||||
// per machine that runs it, delivered to the module running it.
|
// per machine that runs it, delivered to the module running it.
|
||||||
for _, d := range r.Assigned[node] {
|
for _, d := range r.Assigned[node] {
|
||||||
@@ -275,7 +290,8 @@ func Users(r Records) ([]Principal, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, node := range sortedCopy(r.Enrolling) {
|
for _, node := range sortedCopy(r.Enrolling) {
|
||||||
out = append(out, Principal{Kind: KindEnrolment, Node: node})
|
out = append(out, Principal{Kind: KindEnrolment, Node: node, Redeems: servesVerb(r, EnrolmentRedeem),
|
||||||
|
LeaseHandedAway: r.LeaseHandedAway})
|
||||||
}
|
}
|
||||||
for _, person := range sortedNames(r.People) {
|
for _, person := range sortedNames(r.People) {
|
||||||
p := Principal{Kind: KindPerson, Module: person, Invokes: r.People[person]}
|
p := Principal{Kind: KindPerson, Module: person, Invokes: r.People[person]}
|
||||||
@@ -286,6 +302,13 @@ func Users(r Records) ([]Principal, error) {
|
|||||||
out = append(out, p)
|
out = append(out, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The vault's enrolment verbs are mesh-enrolment's alone, however wide another's grant (novox/hq ADR 0587 §1).
|
||||||
|
if slices.ContainsFunc(VerbsTheEnrolmentAsksTheVault, func(v SeatVerb) bool { return servesVerb(r, v) }) {
|
||||||
|
for i := range out {
|
||||||
|
out[i].VaultEnrols = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The grants part creates the durable consumers the accounts need (novox/hq ADR 0561 §6), by stream and name.
|
// The grants part creates the durable consumers the accounts need (novox/hq ADR 0561 §6), by stream and name.
|
||||||
if grants >= 0 {
|
if grants >= 0 {
|
||||||
out[grants].CreatesConsumers = AccountConsumers(out)
|
out[grants].CreatesConsumers = AccountConsumers(out)
|
||||||
@@ -385,6 +408,27 @@ func asksSeatOf(r Records) []Seat {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nodeLoginsOf are the logins composed for node, as Principal.Login names them ("" for `node.<n>`): the one its
|
||||||
|
// node-logins key says is in use and the one rotating, in that order; `node.<n>` alone when the key is absent or does
|
||||||
|
// not hold (a key naming another node's login is no login of this node's).
|
||||||
|
func nodeLoginsOf(r Records, node string) []string {
|
||||||
|
l, ok := r.NodeLogins[node]
|
||||||
|
if !ok || l.Node != node || (l.Login != delivery.FirstLogin(node) && l.Login != delivery.SecondLogin(node)) {
|
||||||
|
return []string{""}
|
||||||
|
}
|
||||||
|
login := func(name string) string {
|
||||||
|
if name == delivery.FirstLogin(node) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
out := []string{login(l.Login)}
|
||||||
|
if l.Rotates() && l.Rotating == delivery.OtherLogin(node, l.Login) {
|
||||||
|
out = append(out, login(l.Rotating))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// VaultSeat is the vault's seat: its holder alone asks the desks and the issuing providers in its own name.
|
// VaultSeat is the vault's seat: its holder alone asks the desks and the issuing providers in its own name.
|
||||||
const VaultSeat = "mesh-vault"
|
const VaultSeat = "mesh-vault"
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,19 @@ func ownModule(p Principal, pattern string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// moduleNamed is the writer that is module: its own principal, a process of it, or the node tools carrying it.
|
||||||
|
func moduleNamed(module string) func(Principal, string) bool {
|
||||||
|
return func(p Principal, _ string) bool {
|
||||||
|
switch p.Kind {
|
||||||
|
case KindModule, KindProcess:
|
||||||
|
return p.Module == module
|
||||||
|
case KindNodeTools:
|
||||||
|
return slices.ContainsFunc(p.Carries, func(d Declared) bool { return d.Module == module })
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ownAnnouncer is a module, a process of it or the node tools carrying it saying what that module keeps, or the
|
// ownAnnouncer is a module, a process of it or the node tools carrying it saying what that module keeps, or the
|
||||||
// controller saying what it keeps itself (novox/hq ADR 0495 clause 2): each under its own name alone.
|
// controller saying what it keeps itself (novox/hq ADR 0495 clause 2): each under its own name alone.
|
||||||
func ownAnnouncer(p Principal, pattern string) bool {
|
func ownAnnouncer(p Principal, pattern string) bool {
|
||||||
@@ -218,6 +231,16 @@ var WritersTable = []WriterRow{
|
|||||||
Writes: ownModule},
|
Writes: ownModule},
|
||||||
{State: "the operator-channel's open messages", Writer: "the seat's holder", KeptIn: "its own key-value state",
|
{State: "the operator-channel's open messages", Writer: "the seat's holder", KeptIn: "its own key-value state",
|
||||||
Others: "—"},
|
Others: "—"},
|
||||||
|
// The seat mesh-enrolment's open enrolments (novox/hq ADR 0587 §2, issue 774): its holder alone writes them, each a
|
||||||
|
// compare-and-set at the revision read; the grants part reads them as a named reader.
|
||||||
|
{State: "the open enrolments", Writer: "the seat mesh-enrolment's holder, compare-and-set",
|
||||||
|
KeptIn: "key-value " + delivery.EnrolmentsBucket + ", with per-key expiry",
|
||||||
|
Others: "the grants part reads them (a named reader) and composes each enrol.<node> user from them",
|
||||||
|
Subjects: kvOf(delivery.EnrolmentsBucket), Writes: moduleNamed(delivery.EnrolmentModule)},
|
||||||
|
// Which login each node uses (novox/hq ADR 0583 §1 insight): the vault alone writes it.
|
||||||
|
{State: "a node's logins", Writer: "the vault", KeptIn: "key-value " + delivery.NodeLoginsBucket,
|
||||||
|
Others: "the grants part reads every key, each node's engine its own node's key alone",
|
||||||
|
Subjects: kvOf(delivery.NodeLoginsBucket), Writes: moduleNamed(delivery.VaultModule)},
|
||||||
{State: "the facts snapshot", Writer: "controller", KeptIn: "the artifact store, facts/latest",
|
{State: "the facts snapshot", Writer: "controller", KeptIn: "the artifact store, facts/latest",
|
||||||
Others: "the build seat reads"},
|
Others: "the build seat reads"},
|
||||||
// What a consumer gave up on (novox/hq issue 330): copied by the controller from the server's notice,
|
// What a consumer gave up on (novox/hq issue 330): copied by the controller from the server's notice,
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
// **Joining a machine is the seat mesh-enrolment, held once** (novox/hq ADR 0587 §1, issue 774): held for the mesh by
|
||||||
|
// the module mesh-enrolment, the only caller the vault's manifest names for its enrolment-token and node-account verbs
|
||||||
|
// and a caller it names for `sign`. Six verbs, five events, none carrying a secret or anything derived from the token:
|
||||||
|
// the one hash an event carries is the digest of the public keys a node presented. It mints and keeps no secret and
|
||||||
|
// holds no signing key. Its state and its key layout are go/delivery's (enrolment.go).
|
||||||
|
|
||||||
|
// EnrolmentSeat is the seat mesh-enrolment.
|
||||||
|
const EnrolmentSeat = "mesh-enrolment"
|
||||||
|
|
||||||
|
// The seat's six verbs (ADR 0587 §1, §4-§8).
|
||||||
|
const (
|
||||||
|
EnrolmentVerbIssue = "issue"
|
||||||
|
EnrolmentVerbRedeem = "redeem"
|
||||||
|
EnrolmentVerbConfirm = "confirm"
|
||||||
|
EnrolmentVerbRevoke = "revoke"
|
||||||
|
EnrolmentVerbTokens = "tokens"
|
||||||
|
EnrolmentVerbAgain = "again"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The seat's five events (ADR 0587 §1, §2, §5): said by its holder on the seat, each carrying an enrolment key's whole
|
||||||
|
// content except enrolment-expired, which names the node and the enrolment-issued event that carried it.
|
||||||
|
const (
|
||||||
|
EnrolmentIssuedEvent = "enrolment-issued"
|
||||||
|
NodeEnrolledEvent = "node-enrolled"
|
||||||
|
EnrolmentRefusedEvent = "enrolment-refused"
|
||||||
|
EnrolmentRevokedEvent = "enrolment-revoked"
|
||||||
|
EnrolmentExpiredEvent = "enrolment-expired"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnrolmentEvents are the seat's events, in the order an enrolment says them; the last four each end one.
|
||||||
|
var EnrolmentEvents = []string{EnrolmentIssuedEvent, NodeEnrolledEvent, EnrolmentRefusedEvent, EnrolmentRevokedEvent,
|
||||||
|
EnrolmentExpiredEvent}
|
||||||
|
|
||||||
|
// enrolmentDecision is the record that made the seat.
|
||||||
|
const enrolmentDecision = "novox/hq ADR 0587"
|
||||||
|
|
||||||
|
// enrolmentVerbs are the seat's verbs: what reads all it holds and does by hand all it does (ADR 0587 §8). Not optional:
|
||||||
|
// the seat is new with its holder, so no holder of before serves fewer.
|
||||||
|
func enrolmentVerbs() []Verb {
|
||||||
|
str := func(description string) map[string]any {
|
||||||
|
return map[string]any{"type": "string", "description": description}
|
||||||
|
}
|
||||||
|
return []Verb{
|
||||||
|
{Name: EnrolmentVerbIssue, Description: "Issue a one-time token for a new machine, on the operator's Approve: " +
|
||||||
|
"refused at once, naming every finding, for a name that is not a node name or that a node record, a placement " +
|
||||||
|
"key or an open enrolment holds, and for every call before the controller's lease is handed away. Otherwise " +
|
||||||
|
"reserves the lowest free private-network address, asks the operator under the seat's own name and, on the " +
|
||||||
|
"warrant, adds the node to placement with its tunnel key, asks the vault for the token and says enrolment-issued. " +
|
||||||
|
"The token itself is read only through tokens.",
|
||||||
|
Input: schema(map[string]string{
|
||||||
|
"node": "the new machine's name in the mesh",
|
||||||
|
"why": "why it joins, shown to the operator with the ask",
|
||||||
|
"expires": "how long the token lasts, a duration such as 1h or 24h: one hour when not given, at most 24 hours",
|
||||||
|
// The operator's decision of 2026-11-02 01:05: the tunnel key is given at issue, so on the Approve the node
|
||||||
|
// enters placement with it and the hub peers it before redeem.
|
||||||
|
"overlay_key": "the new machine's private network (tunnel) public key, as `nox-mesh-host key` prints it",
|
||||||
|
}, []string{"node", "why", "overlay_key"})},
|
||||||
|
{Name: EnrolmentVerbRedeem, Description: "Spend a token: asked by a joining node-engine dialled as enrol.<node> " +
|
||||||
|
"alone, with the public halves of its keys and its identity key's proof over them. Spends the token, asks the " +
|
||||||
|
"vault for the node's account and certificate, writes the node into placement, and answers the account sealed " +
|
||||||
|
"to the node, its certificate and the vault's public half, signed by the vault. Asked again with the same keys " +
|
||||||
|
"it finishes what is not done and answers the same; with other keys it is refused.",
|
||||||
|
Input: map[string]any{"type": "object", "additionalProperties": false,
|
||||||
|
"required": []string{"node", "public_key", "sealing_key", "proof"},
|
||||||
|
"properties": map[string]any{
|
||||||
|
"node": str("the node the token was issued for"),
|
||||||
|
"public_key": str("the identity key's public half, base64"),
|
||||||
|
"overlay_key": str("the private network key's public half"),
|
||||||
|
"sealing_key": str("the sealing key's public half, which the account is sealed to"),
|
||||||
|
"serving_key": str("the serving key's public half, which the certificate binds"),
|
||||||
|
"profile": map[string]any{"type": "object", "description": "what the machine is, as its record says it"},
|
||||||
|
"tunnel": map[string]any{"type": "object", "description": "a tunnel found on the machine: everything but its private key"},
|
||||||
|
"proof": str("the identity key's signature over the redeem's proof bytes (delivery.RedeemProof), base64"),
|
||||||
|
}}},
|
||||||
|
{Name: EnrolmentVerbConfirm, Description: "Confirm an enrolment: asked by the node-engine dialled as its own " +
|
||||||
|
"node login alone, the runtime's proof of that caller being the confirmation. Says node-enrolled and ends the " +
|
||||||
|
"enrolment.",
|
||||||
|
Input: schema(map[string]string{}, nil)},
|
||||||
|
{Name: EnrolmentVerbRevoke, Description: "End an enrolment at any stage, with why, without an Approve: withdraws " +
|
||||||
|
"an open ask, deletes the node's key and its address, has the vault retire an account it already minted for " +
|
||||||
|
"it, records the act as a hand act and says enrolment-revoked.",
|
||||||
|
Input: schema(map[string]string{"node": "the node", "why": "why, recorded with the hand act"},
|
||||||
|
[]string{"node", "why"})},
|
||||||
|
{Name: EnrolmentVerbTokens, Description: "Read every open enrolment, or one: its stage, expiry, ask and revision, " +
|
||||||
|
"the bucket's fill against its cap, and whether issuing is on and why (the lease). For a node at live, its " +
|
||||||
|
"token sealed to the operator key, asked of the vault at that moment and relayed as the vault sealed it. With " +
|
||||||
|
"history, the ended enrolments from the seat's events, as far back as the events are kept.",
|
||||||
|
Input: schema(map[string]string{"node": "one node", "history": "\"true\": the ended enrolments too"},
|
||||||
|
nil, "history")},
|
||||||
|
{Name: EnrolmentVerbAgain, Description: "Raise an issue's ask again when the operator's channel does not hold it, " +
|
||||||
|
"and nothing more.",
|
||||||
|
Input: schema(map[string]string{"node": "the node whose ask is raised again"}, []string{"node"})},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,10 @@ func placementVerbs() []Verb {
|
|||||||
"place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), " +
|
"place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), " +
|
||||||
"unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide " +
|
"unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide " +
|
||||||
"(node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, " +
|
"(node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, " +
|
||||||
"layer, mesh-wide-values, values)",
|
"layer, mesh-wide-values, values), add-node (node, decided, keys: a joining node, mesh-enrolment's alone, the " +
|
||||||
|
"proposal's one change, with the enrolment's warrant, its tunnel key alone at the operator's yes; ADR 0587 §7), " +
|
||||||
|
"remove-node (node: a joining node never confirmed, taken off when its enrolment ends; mesh-enrolment's alone, " +
|
||||||
|
"on the same warrant)",
|
||||||
"items": map[string]any{"type": "object", "required": []string{"op", "node"}}}
|
"items": map[string]any{"type": "object", "required": []string{"op", "node"}}}
|
||||||
return []Verb{
|
return []Verb{
|
||||||
{Name: PlacementVerbPlacement, Optional: true, Description: "Read placement: one node's key, the nodes one " +
|
{Name: PlacementVerbPlacement, Optional: true, Description: "Read placement: one node's key, the nodes one " +
|
||||||
@@ -52,11 +55,17 @@ func placementVerbs() []Verb {
|
|||||||
"on one host port, a provider not placed, a seat held once held twice, two nodes with one address, two " +
|
"on one host port, a provider not placed, a seat held once held twice, two nodes with one address, two " +
|
||||||
"hubs, a module the indexer does not hold, a node with no record). Otherwise the nodes it touches compose " +
|
"hubs, a module the indexer does not hold, a node with no record). Otherwise the nodes it touches compose " +
|
||||||
"it in shadow and answer, and on a passing verdict the operator is asked to approve it. Who proposes is the " +
|
"it in shadow and answer, and on a passing verdict the operator is asked to approve it. Who proposes is the " +
|
||||||
"caller, as the runtime proves it.",
|
"caller, as the runtime proves it. An add-node from mesh-enrolment, whose warrant names its node and address, " +
|
||||||
|
"is written with no ask of its own: its Approve is the enrolment's (ADR 0587 §7).",
|
||||||
Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"why", "changes"},
|
Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"why", "changes"},
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
"why": map[string]any{"type": "string", "description": "why, shown to the operator with the proposal"},
|
"why": map[string]any{"type": "string", "description": "why, shown to the operator with the proposal"},
|
||||||
"changes": changes,
|
"changes": changes,
|
||||||
|
// ADR 0587 §7: an add-node carries the warrant of the enrolment that issued the node's token, whose
|
||||||
|
// Approve is the proposal's; the holder writes it with no ask of its own.
|
||||||
|
"warrant": map[string]any{"type": "object", "description": "for an add-node alone: the enrolment's " +
|
||||||
|
"warrant {node, address, ask, warrant}, naming the change's node and address",
|
||||||
|
"required": []string{"node", "address", "ask", "warrant"}},
|
||||||
}}},
|
}}},
|
||||||
{Name: PlacementVerbWithdraw, Optional: true, Description: "Withdraw an open proposal: its key and its " +
|
{Name: PlacementVerbWithdraw, Optional: true, Description: "Withdraw an open proposal: its key and its " +
|
||||||
"candidates are deleted and placement-withdrawn carries it whole. With why.",
|
"candidates are deleted and placement-withdrawn carries it whole. With why.",
|
||||||
|
|||||||
+54
-6
@@ -233,13 +233,59 @@ var defaultSeats = append([]Seat{
|
|||||||
"controller's, never anew for one the vault holds a value or an adopted hash for. Never the value.",
|
"controller's, never anew for one the vault holds a value or an adopted hash for. Never the value.",
|
||||||
Input: schema(map[string]string{"user": "the bus user, e.g. ace.mesh-build-go"}, []string{"user"})},
|
Input: schema(map[string]string{"user": "the bus user, e.g. ace.mesh-build-go"}, []string{"user"})},
|
||||||
{Name: "sign", Optional: true, Description: "Sign with the vault's key or authority (novox/hq ADR 0550 §2): " +
|
{Name: "sign", Optional: true, Description: "Sign with the vault's key or authority (novox/hq ADR 0550 §2): " +
|
||||||
"kind node-certificate signs the certificate of the calling machine's internal name for the serving key " +
|
"kind node-certificate signs the certificate of a machine's internal name, asked by that machine's " +
|
||||||
"its record states, built from bus state alone and asked by that machine's node-engine only; kind " +
|
"node-engine for the serving key its record states, built from bus state alone, or by mesh-enrolment for " +
|
||||||
"enrolment signs an enrolment reply under its own context, for a caller the adoption record names. " +
|
"a node it enrols, for the serving key the node presented at redeem (ADR 0587 §3); kind enrolment signs a " +
|
||||||
"Answers the certificate or the signature and the signer's fingerprint, and says signed; never a key.",
|
"redeem answer under its own context, for mesh-enrolment alone. Answers the certificate or the signature " +
|
||||||
|
"and the signer's fingerprint, and says signed; never a key.",
|
||||||
Input: schema(map[string]string{"kind": "node-certificate or enrolment",
|
Input: schema(map[string]string{"kind": "node-certificate or enrolment",
|
||||||
"node": "the machine, for a node certificate: the caller's own",
|
"node": "the machine, for a node certificate: the caller's own, or the node mesh-enrolment enrols",
|
||||||
"reply": "the enrolment reply's bytes in base64, for an enrolment"}, []string{"kind"})},
|
"serving_key": "from mesh-enrolment: the serving key's public half the enrolling node presented",
|
||||||
|
"reply": "the redeem answer's bytes in base64, for an enrolment"}, []string{"kind"})},
|
||||||
|
// Enrolment (novox/hq ADR 0561 §5, ADR 0587 §3, §5; issue 774): mesh-enrolment is the only caller the vault's
|
||||||
|
// manifest names for these two.
|
||||||
|
{Name: "enrolment-token", Optional: true, Description: "Make a one-time token for a node mesh-enrolment " +
|
||||||
|
"issues on the operator's warrant: its secret kept with its copy sealed to the operator key and its hash " +
|
||||||
|
"written as the bus user enrol.<node>. Answers only what the joining machine must trust (the bus's " +
|
||||||
|
"address, the bus certificate's fingerprint, the vault's public signing key), never the token; with " +
|
||||||
|
"sealed, the token sealed to the operator key, for tokens to relay; with drop, the token and its hash " +
|
||||||
|
"dropped, at the enrolment's end. Asked by mesh-enrolment alone, only from the lease write. The token " +
|
||||||
|
"carries the first tunnel mesh-enrolment built at issue (ADR 0587 §3, insight of 2026-11-02).",
|
||||||
|
Input: withObject(schema(map[string]string{"node": "the node the token is for",
|
||||||
|
"expires": "when the token expires (RFC 3339), as the enrolment's key says",
|
||||||
|
"sealed": "\"true\": answer the token sealed to the operator key",
|
||||||
|
"drop": "\"true\": drop the node's token and its hash"}, []string{"node"}, "sealed", "drop"),
|
||||||
|
"tunnel", "the node's first tunnel, built by mesh-enrolment at issue and carried in the token: the hub's "+
|
||||||
|
"public key, the hub's endpoint, the node's tunnel address, its own tunnel key and the network's range",
|
||||||
|
map[string]string{"hub_key": "the hub's tunnel public key", "hub_endpoint": "the hub's endpoint, host:port",
|
||||||
|
"address": "the joining node's tunnel address",
|
||||||
|
"key": "the joining machine's own tunnel public key, as given at issue",
|
||||||
|
"range": "the private network's range, a prefix"})},
|
||||||
|
{Name: "node-account", Optional: true, Description: "Mint a joining node's bus account node.<node>, sealed " +
|
||||||
|
"to the sealing key it presented, and write its hash; asked again for the same node and keys, answers the " +
|
||||||
|
"account it already minted; with retire, the account it minted for a revoked enrolment retired. Writes the " +
|
||||||
|
"node's node-logins key naming node.<node>. Asked by mesh-enrolment alone. Never the value in the clear.",
|
||||||
|
Input: schema(map[string]string{"node": "the joining node",
|
||||||
|
"sealing_key": "the sealing key's public half the node presented, which the account is sealed to",
|
||||||
|
"keys_digest": "the digest of every public key the node presented (delivery.KeysDigest)",
|
||||||
|
"retire": "\"true\": retire the account minted for this node"}, []string{"node"}, "retire")},
|
||||||
|
// A node's account rotates over two logins (novox/hq ADR 0583 §2-§5; issue 774), one node at a time, the bus
|
||||||
|
// node last; the state node-logins says where each node stands.
|
||||||
|
{Name: "rotate-node", Optional: true, Description: "Begin a node's account rotation to its other login: " +
|
||||||
|
"answered only for a call carrying a hand act recorded through mesh-hand-acts, which the vault reads back by " +
|
||||||
|
"its id. Makes the other login's value sealed to that node, writes it rotating in node-logins and says " +
|
||||||
|
"rotation-begun; says node-account-ready once the bus node's have shows a list holding both logins. One " +
|
||||||
|
"node at a time across the mesh, the bus node last: a call while a rotation runs waits until it is done.",
|
||||||
|
Input: schema(map[string]string{"node": "the node whose account rotates", "why": "why, in a sentence",
|
||||||
|
"act": "the id of the hand act recorded for it in mesh-hand-acts"}, []string{"node", "why", "act"})},
|
||||||
|
{Name: "node-account-confirm", Optional: true, Description: "Confirm a node's switch to its new login: " +
|
||||||
|
"asked by the node-engine dialled as that login, the runtime's proof of the caller being the confirmation. " +
|
||||||
|
"The old login's hash is then removed, and rotation-done said once the bus node's have shows the list " +
|
||||||
|
"without it.",
|
||||||
|
Input: schema(map[string]string{}, nil)},
|
||||||
|
{Name: "abandon", Optional: true, Description: "Abandon a node's rotation before its old login is retired: " +
|
||||||
|
"the new login and its value dropped, the old one kept; a node that switched switches back first.",
|
||||||
|
Input: schema(map[string]string{"node": "the node whose rotation is abandoned"}, []string{"node"})},
|
||||||
}},
|
}},
|
||||||
// The owner of every config value (novox/hq ADR 0348 §9, design 57 K3): its holder alone writes the buckets
|
// The owner of every config value (novox/hq ADR 0348 §9, design 57 K3): its holder alone writes the buckets
|
||||||
// mesh-config_values, _resolved, _predefined and _proposals, and serves the one verb `config` (§1, §10).
|
// mesh-config_values, _resolved, _predefined and _proposals, and serves the one verb `config` (§1, §10).
|
||||||
@@ -305,6 +351,8 @@ var defaultSeats = append([]Seat{
|
|||||||
// The lease write (novox/hq ADR 0582 clause 1, grants_seat.go): the grants part hands the controller's lease to
|
// The lease write (novox/hq ADR 0582 clause 1, grants_seat.go): the grants part hands the controller's lease to
|
||||||
// itself and back on the operator's warrant.
|
// itself and back on the operator's warrant.
|
||||||
{Name: GrantsSeat, Scope: ScopeMesh, Decision: grantsDecision, Emits: grantsEvents(), Serves: grantsVerbs()},
|
{Name: GrantsSeat, Scope: ScopeMesh, Decision: grantsDecision, Emits: grantsEvents(), Serves: grantsVerbs()},
|
||||||
|
// Joining a machine (novox/hq ADR 0587 §1, enrolment_seat.go): held once by the module mesh-enrolment.
|
||||||
|
{Name: EnrolmentSeat, Scope: ScopeMesh, Decision: enrolmentDecision, Emits: EnrolmentEvents, Serves: enrolmentVerbs()},
|
||||||
// The artifact sweep (novox/hq ADR 0571 clause 2, artifact_sweep_seat.go).
|
// The artifact sweep (novox/hq ADR 0571 clause 2, artifact_sweep_seat.go).
|
||||||
{Name: ArtifactSweepSeat, Scope: ScopeMesh, Decision: "novox/hq ADR 0571", Emits: []string{"artifacts-gone"},
|
{Name: ArtifactSweepSeat, Scope: ScopeMesh, Decision: "novox/hq ADR 0571", Emits: []string{"artifacts-gone"},
|
||||||
Serves: artifactSweepVerbs()},
|
Serves: artifactSweepVerbs()},
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
// The vault's provision is one the controller itself dereferences — every minted credential is
|
// The vault's provision is one the controller itself dereferences — every minted credential is
|
||||||
// sealed with it — so it is delivered by a seat of the mesh's own, and a second provider is a second
|
// sealed with it — so it is delivered by a seat of the mesh's own, and a second provider is a second
|
||||||
@@ -65,6 +68,14 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
|
|||||||
"secret-ask": {"node", "module", "name", "desk", "asked_by", "outcome", "call", "sealed"},
|
"secret-ask": {"node", "module", "name", "desk", "asked_by", "outcome", "call", "sealed"},
|
||||||
"issued": {"provision", "provider", "module", "call", "sealed", "holders"},
|
"issued": {"provision", "provider", "module", "call", "sealed", "holders"},
|
||||||
"rotate": {"node", "module", "name", "provider", "provision", "by"},
|
"rotate": {"node", "module", "name", "provider", "provision", "by"},
|
||||||
|
// 774/S37/AC1: the enrolment verbs mesh-enrolment asks (ADR 0587 §3, §5).
|
||||||
|
"enrolment-token": {"node", "expires", "sealed", "drop", "tunnel"},
|
||||||
|
"node-account": {"node", "sealing_key", "keys_digest", "retire"},
|
||||||
|
"sign": {"kind", "node", "serving_key", "reply"},
|
||||||
|
// 774/S38: the rotation verbs (ADR 0583 §2, §3, §5).
|
||||||
|
"rotate-node": {"node", "why", "act"},
|
||||||
|
"node-account-confirm": {},
|
||||||
|
"abandon": {"node"},
|
||||||
}
|
}
|
||||||
for name, args := range want {
|
for name, args := range want {
|
||||||
var verb *Verb
|
var verb *Verb
|
||||||
@@ -89,6 +100,14 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
|
|||||||
if props["value"] != nil {
|
if props["value"] != nil {
|
||||||
t.Errorf("%s takes a value in the clear", name)
|
t.Errorf("%s takes a value in the clear", name)
|
||||||
}
|
}
|
||||||
|
// The first tunnel the token carries (ADR 0587 §3, insight of 2026-11-02): an object of the hub's key, its endpoint
|
||||||
|
// and the node's address.
|
||||||
|
if name == "enrolment-token" {
|
||||||
|
tunnel, _ := props["tunnel"].(map[string]any)
|
||||||
|
if tunnel["type"] != "object" || !slices.Equal(tunnel["required"].([]string), []string{"address", "hub_endpoint", "hub_key", "key", "range"}) {
|
||||||
|
t.Errorf("enrolment-token's tunnel is %v", tunnel)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// The holders `issued` names (issue 749) are a list of {node, module}.
|
// The holders `issued` names (issue 749) are a list of {node, module}.
|
||||||
for _, verb := range seat.Serves {
|
for _, verb := range seat.Serves {
|
||||||
@@ -105,7 +124,8 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}},
|
serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}},
|
||||||
Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh,
|
Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh,
|
||||||
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate"}}}}
|
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate", "sign", "enrolment-token", "node-account",
|
||||||
|
"rotate-node", "node-account-confirm", "abandon"}}}}
|
||||||
if err := CanHold(serving, seat); err != nil {
|
if err := CanHold(serving, seat); err != nil {
|
||||||
t.Fatalf("the vault serving its verbs was refused: %v", err)
|
t.Fatalf("the vault serving its verbs was refused: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"slices"
|
||||||
)
|
)
|
||||||
|
|
||||||
// A Verb is one tool a role serves: its name, what it does, and the schema of its arguments and of
|
// A Verb is one tool a role serves: its name, what it does, and the schema of its arguments and of
|
||||||
@@ -596,6 +598,14 @@ var ControllerVerbs = []Verb{
|
|||||||
//
|
//
|
||||||
// **The schema is the whole of what a verb takes** (novox/hq issue 244): an argument it does not
|
// **The schema is the whole of what a verb takes** (novox/hq issue 244): an argument it does not
|
||||||
// name is refused when the verb is called, never passed over.
|
// name is refused when the verb is called, never passed over.
|
||||||
|
// withObject adds to a verb's input s the property name, an object of the string fields given, each required.
|
||||||
|
func withObject(s map[string]any, name, description string, fields map[string]string) map[string]any {
|
||||||
|
inner := schema(fields, slices.Sorted(maps.Keys(fields)))
|
||||||
|
inner["description"], inner["additionalProperties"] = description, false
|
||||||
|
s["properties"].(map[string]any)[name] = inner
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
func schema(properties map[string]string, required []string, switches ...string) map[string]any {
|
func schema(properties map[string]string, required []string, switches ...string) map[string]any {
|
||||||
isSwitch := map[string]bool{}
|
isSwitch := map[string]bool{}
|
||||||
for _, s := range switches {
|
for _, s := range switches {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -25,6 +26,30 @@ func TestAServedVerbIsANameOrADefinition(t *testing.T) {
|
|||||||
|
|
||||||
// Holding a mesh seat that serves verbs requires serving them, and the refusal names the verbs.
|
// Holding a mesh seat that serves verbs requires serving them, and the refusal names the verbs.
|
||||||
func TestHoldingAMeshSeatRequiresServingItsVerbs(t *testing.T) {
|
func TestHoldingAMeshSeatRequiresServingItsVerbs(t *testing.T) {
|
||||||
|
// 774/S34/AC1: the seat mesh-enrolment is held by a module serving its six verbs, and a seventh is refused.
|
||||||
|
enrolment, ok := SeatNamed(EnrolmentSeat)
|
||||||
|
if !ok || enrolment.Scope != ScopeMesh || len(enrolment.Emits) != 5 {
|
||||||
|
t.Fatalf("mesh-enrolment: in the set %v, %+v", ok, enrolment)
|
||||||
|
}
|
||||||
|
six := []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}
|
||||||
|
// The tunnel key is given at issue (the operator, 2026-11-02 01:05).
|
||||||
|
if issue := enrolment.Serves[0]; issue.Name != "issue" || !slices.Contains(issue.Input["required"].([]string), "overlay_key") {
|
||||||
|
t.Errorf("issue requires %v", issue.Input["required"])
|
||||||
|
}
|
||||||
|
holder := func(serves ...string) Manifest {
|
||||||
|
return Manifest{Module: "mesh-enrolment", Tools: serves,
|
||||||
|
Claims: []Claim{{Name: EnrolmentSeat, Scope: ScopeMesh, Serves: serves}}}
|
||||||
|
}
|
||||||
|
if err := CanHold(holder(six...), enrolment); err != nil {
|
||||||
|
t.Errorf("the holder serving the six verbs was refused: %v", err)
|
||||||
|
}
|
||||||
|
if err := CanHold(holder(six[:5]...), enrolment); err == nil || !strings.Contains(err.Error(), "does not serve again") {
|
||||||
|
t.Errorf("a holder missing again: %v", err)
|
||||||
|
}
|
||||||
|
if err := CanHold(holder(append(six, "mint")...), enrolment); err == nil || !strings.Contains(err.Error(), "mint") {
|
||||||
|
t.Errorf("a holder saying it serves mint: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
was := Seats()
|
was := Seats()
|
||||||
t.Cleanup(func() { UseSeats(was) })
|
t.Cleanup(func() { UseSeats(was) })
|
||||||
UseSeats([]Seat{{Name: "mesh-controller", Scope: ScopeMesh, Decision: "test",
|
UseSeats([]Seat{{Name: "mesh-controller", Scope: ScopeMesh, Decision: "test",
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ func TestTheControlRecordsReadTheirWritersValues(t *testing.T) {
|
|||||||
{"a node's health report, a part pointing at the hub", `{"v":1,"node":"anchor","network":{"state":"unhealthy",` +
|
{"a node's health report, a part pointing at the hub", `{"v":1,"node":"anchor","network":{"state":"unhealthy",` +
|
||||||
`"since":"2026-10-29T20:00:00Z","parts":[{"part":"tunnel","state":"unhealthy","reason":"the tunnel to the hub has not handshaken for over five minutes",` +
|
`"since":"2026-10-29T20:00:00Z","parts":[{"part":"tunnel","state":"unhealthy","reason":"the tunnel to the hub has not handshaken for over five minutes",` +
|
||||||
`"toward":["hub"],"targets":["novox"],"since":"2026-10-29T20:00:00Z"}]},"at":"2026-10-29T20:00:00Z"}`, &NodeHealth{}},
|
`"toward":["hub"],"targets":["novox"],"since":"2026-10-29T20:00:00Z"}]},"at":"2026-10-29T20:00:00Z"}`, &NodeHealth{}},
|
||||||
|
// A node fallen back to its old login (ADR 0583 §6), which the watchman raises as node-account-fallback.
|
||||||
|
{"a node's health report, fallen back to its old login", `{"v":1,"node":"anchor","fallback":{"node":"anchor",` +
|
||||||
|
`"from":"node.anchor--b","why":"the new login's test-dial was refused","at":"2026-11-02T01:00:00Z"},` +
|
||||||
|
`"at":"2026-11-02T01:00:00Z"}`, &NodeHealth{}},
|
||||||
{"the predefined list", `{"v":1,"commit":"abc","at":"2026-10-29T12:00:00Z","list":{"domain":{"type":"string"}}}`, &PredefinedKept{}},
|
{"the predefined list", `{"v":1,"commit":"abc","at":"2026-10-29T12:00:00Z","list":{"domain":{"type":"string"}}}`, &PredefinedKept{}},
|
||||||
} {
|
} {
|
||||||
if err := json.Unmarshal([]byte(c.raw), c.into); err != nil {
|
if err := json.Unmarshal([]byte(c.raw), c.into); err != nil {
|
||||||
@@ -60,6 +64,10 @@ func TestTheControlRecordsReadTheirWritersValues(t *testing.T) {
|
|||||||
if _, err := EncodeNodeHealth(NodeHealth{}); err == nil {
|
if _, err := EncodeNodeHealth(NodeHealth{}); err == nil {
|
||||||
t.Error("a report naming no node was encoded")
|
t.Error("a report naming no node was encoded")
|
||||||
}
|
}
|
||||||
|
if _, err := EncodeNodeHealth(NodeHealth{Node: "anchor", Fallback: &LoginFallback{Node: "ace", From: "node.anchor--b",
|
||||||
|
Why: "refused", At: time.Now()}}); err == nil {
|
||||||
|
t.Error("a fall back naming another node was encoded")
|
||||||
|
}
|
||||||
// ADR 0571 clause 4: who holds which seat, from placement; a node seat by its node.
|
// ADR 0571 clause 4: who holds which seat, from placement; a node seat by its node.
|
||||||
held := SeatHoldersOf(map[string]Placement{
|
held := SeatHoldersOf(map[string]Placement{
|
||||||
"ace": {Node: "ace", Holds: []Holding{{Claim: "mesh-dns-resolver", Scope: "mesh", Module: "dnsmasq"}, {Claim: "node-files", Scope: "node", Module: "mesh-files"}}},
|
"ace": {Node: "ace", Holds: []Holding{{Claim: "mesh-dns-resolver", Scope: "mesh", Module: "dnsmasq"}, {Claim: "node-files", Scope: "node", Module: "mesh-files"}}},
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// **The state of the seat mesh-enrolment** (novox/hq ADR 0587 §2, issue 774): the holder's bucket `enrolments`, holding
|
||||||
|
// no secret, with two kinds of key. `node.<node>`, one per open enrolment, holds an Enrolment; `address.<address>`
|
||||||
|
// reserves one private-network address for one open enrolment and holds the node's name and, from the ask, the ask and the issue's time. Every write is a
|
||||||
|
// compare-and-set at the revision read. The bucket keeps per-key expiry (`key-ttl`): a key at stage live carries the
|
||||||
|
// token's expiry, at asked and redeeming none (§5). It is read by the holder and, as a named reader, by the grants part,
|
||||||
|
// which composes the user `enrol.<node>` from a key at live and the vault's hash (§4). These constants are the seat's
|
||||||
|
// contract: every reader takes the names from here.
|
||||||
|
const (
|
||||||
|
// EnrolmentModule is the module holding the seat mesh-enrolment.
|
||||||
|
EnrolmentModule = catalogue.EnrolmentSeat
|
||||||
|
// EnrolmentsState is its state, EnrolmentsBucket the bucket, EnrolmentsRead the name a reader's manifest declares.
|
||||||
|
EnrolmentsState = "enrolments"
|
||||||
|
EnrolmentsBucket = EnrolmentModule + "_" + EnrolmentsState
|
||||||
|
EnrolmentsRead = EnrolmentModule + "." + EnrolmentsState
|
||||||
|
|
||||||
|
// The stages of an open enrolment (§2, §4, §5).
|
||||||
|
EnrolmentAsked = "asked"
|
||||||
|
EnrolmentLive = "live"
|
||||||
|
EnrolmentRedeeming = "redeeming"
|
||||||
|
|
||||||
|
// DefaultTokenLife is a token's life when issue names none; MostTokenLife the longest it may name (§4).
|
||||||
|
DefaultTokenLife = time.Hour
|
||||||
|
MostTokenLife = 24 * time.Hour
|
||||||
|
|
||||||
|
// EnrolmentUserPrefix is the bus user a joining machine dials as, `enrol.<node>` (§4).
|
||||||
|
EnrolmentUserPrefix = "enrol."
|
||||||
|
|
||||||
|
enrolmentNodePrefix = "node."
|
||||||
|
enrolmentAddressPrefix = "address."
|
||||||
|
)
|
||||||
|
|
||||||
|
// Enrolment is one key `node.<node>` of `enrolments`: the node's name, the private-network address chosen at issue, the
|
||||||
|
// why, the caller as the runtime proved it, the ask's id, the stage, when it was issued and when the token expires, and
|
||||||
|
// from redeem the digest of the public keys presented (KeysDigest). No secret and nothing derived from the token.
|
||||||
|
type Enrolment struct {
|
||||||
|
V int `json:"v"`
|
||||||
|
Node string `json:"node"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Why string `json:"why"`
|
||||||
|
Caller string `json:"caller"`
|
||||||
|
Ask string `json:"ask,omitempty"`
|
||||||
|
Stage string `json:"stage"`
|
||||||
|
Issued time.Time `json:"issued"`
|
||||||
|
Expires time.Time `json:"expires"`
|
||||||
|
KeysDigest string `json:"keys-digest,omitempty"`
|
||||||
|
// OverlayKey is the machine's tunnel public key, given at issue (the operator's decision of 2026-11-02 01:05),
|
||||||
|
// base64 of 32 bytes: at every stage, since the node enters placement with it on the operator's yes.
|
||||||
|
OverlayKey string `json:"overlay-key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnrolmentAddress is one key `address.<address>`: the node the address is reserved for and, from when the issue's ask
|
||||||
|
// is raised, that ask's id and the issue's time (ADR 0589 §6), so the reservation names the enrolment it holds the
|
||||||
|
// address for. The key is written when the address is reserved, before the ask: Ask and Issued are then both empty, and
|
||||||
|
// from the ask both set; one without the other is refused.
|
||||||
|
type EnrolmentAddress struct {
|
||||||
|
V int `json:"v"`
|
||||||
|
Node string `json:"node"`
|
||||||
|
Ask string `json:"ask,omitempty"`
|
||||||
|
Issued time.Time `json:"issued,omitzero"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a EnrolmentAddress) check() error {
|
||||||
|
if err := checkName("node", a.Node); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if (strings.TrimSpace(a.Ask) == "") != a.Issued.IsZero() {
|
||||||
|
return fmt.Errorf("delivery: the address reserved for %s names its ask without the issue's time, or the reverse", a.Node)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnrolmentIssuedID is the id of the enrolment-issued event of node's enrolment issued at issued:
|
||||||
|
// `enrolment-issued.<node>.<issue time in Unix nanoseconds>`, what enrolment-expired names (EnrolmentExpiry).
|
||||||
|
func EnrolmentIssuedID(node string, issued time.Time) string {
|
||||||
|
return catalogue.EnrolmentIssuedEvent + "." + node + "." + strconv.FormatInt(issued.UnixNano(), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnrolmentNodeKey is a node's key in `enrolments`.
|
||||||
|
func EnrolmentNodeKey(node string) string { return enrolmentNodePrefix + node }
|
||||||
|
|
||||||
|
// EnrolmentAddressKey is an address's key in `enrolments`: the address as it is, its dots kept (a key's tokens).
|
||||||
|
func EnrolmentAddressKey(address string) string { return enrolmentAddressPrefix + address }
|
||||||
|
|
||||||
|
// EnrolmentKeyNode is the node a key `node.<node>` names, and whether it is one.
|
||||||
|
func EnrolmentKeyNode(key string) (string, bool) {
|
||||||
|
node, ok := strings.CutPrefix(key, enrolmentNodePrefix)
|
||||||
|
return node, ok && checkName("node", node) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnrolmentKeyAddress is the address a key `address.<address>` names, and whether it is one.
|
||||||
|
func EnrolmentKeyAddress(key string) (string, bool) {
|
||||||
|
address, ok := strings.CutPrefix(key, enrolmentAddressPrefix)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
_, err := netip.ParseAddr(address)
|
||||||
|
return address, err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnrolmentUser is the bus user a node enrols as.
|
||||||
|
func EnrolmentUser(node string) string { return EnrolmentUserPrefix + node }
|
||||||
|
|
||||||
|
func (e Enrolment) check() error {
|
||||||
|
if err := checkName("node", e.Node); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := netip.ParseAddr(e.Address); err != nil {
|
||||||
|
return fmt.Errorf("delivery: the enrolment of %s names the address %q, which is not an address", e.Node, e.Address)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(e.Why) == "" || strings.TrimSpace(e.Caller) == "" {
|
||||||
|
return fmt.Errorf("delivery: the enrolment of %s names no why or no caller", e.Node)
|
||||||
|
}
|
||||||
|
if k, err := base64.StdEncoding.DecodeString(e.OverlayKey); err != nil || len(k) != 32 {
|
||||||
|
return fmt.Errorf("delivery: the enrolment of %s names no tunnel key of 32 bytes in base64", e.Node)
|
||||||
|
}
|
||||||
|
switch e.Stage {
|
||||||
|
case EnrolmentAsked, EnrolmentLive:
|
||||||
|
if e.KeysDigest != "" {
|
||||||
|
return fmt.Errorf("delivery: the enrolment of %s at %s names presented keys, which only a redeem gives", e.Node, e.Stage)
|
||||||
|
}
|
||||||
|
case EnrolmentRedeeming:
|
||||||
|
if err := checkDigest(e.KeysDigest, "the enrolment's keys digest"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("delivery: the enrolment of %s is at stage %q, not asked, live or redeeming", e.Node, e.Stage)
|
||||||
|
}
|
||||||
|
if e.Issued.IsZero() || !e.Expires.After(e.Issued) {
|
||||||
|
return fmt.Errorf("delivery: the enrolment of %s names no issue or an expiry not after it", e.Node)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeEnrolment is e as JSON at Version; it refuses what DecodeEnrolment refuses.
|
||||||
|
func EncodeEnrolment(e Enrolment) ([]byte, error) {
|
||||||
|
if err := e.check(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.V = Version
|
||||||
|
return json.Marshal(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeEnrolment reads a key `node.<node>`; it refuses an unknown version (ErrUnknownVersion), a node that is not a
|
||||||
|
// name, an address that is not one, an unknown stage, presented keys before redeem or none at it, and an expiry not
|
||||||
|
// after the issue.
|
||||||
|
func DecodeEnrolment(data []byte) (Enrolment, error) {
|
||||||
|
var e Enrolment
|
||||||
|
if err := decode(data, &e); err != nil {
|
||||||
|
return Enrolment{}, err
|
||||||
|
}
|
||||||
|
if err := e.check(); err != nil {
|
||||||
|
return Enrolment{}, err
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeEnrolmentAddress is a key `address.<address>` reserving it for node: with no ask and a zero issued when it is
|
||||||
|
// reserved, with the issue's ask and time once the ask is raised. It refuses what DecodeEnrolmentAddress refuses.
|
||||||
|
func EncodeEnrolmentAddress(node, ask string, issued time.Time) ([]byte, error) {
|
||||||
|
a := EnrolmentAddress{V: Version, Node: node, Ask: ask, Issued: issued}
|
||||||
|
if err := a.check(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeEnrolmentAddress reads a key `address.<address>`; it refuses no node, and an ask without the issue's time or
|
||||||
|
// the reverse.
|
||||||
|
func DecodeEnrolmentAddress(data []byte) (EnrolmentAddress, error) {
|
||||||
|
var a EnrolmentAddress
|
||||||
|
if err := decode(data, &a); err != nil {
|
||||||
|
return EnrolmentAddress{}, err
|
||||||
|
}
|
||||||
|
if err := a.check(); err != nil {
|
||||||
|
return EnrolmentAddress{}, err
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenLife is the life issue's `expires` names: DefaultTokenLife when empty, refused when it is not a duration, not
|
||||||
|
// positive or longer than MostTokenLife.
|
||||||
|
func TokenLife(expires string) (time.Duration, error) {
|
||||||
|
if strings.TrimSpace(expires) == "" {
|
||||||
|
return DefaultTokenLife, nil
|
||||||
|
}
|
||||||
|
d, err := time.ParseDuration(strings.TrimSpace(expires))
|
||||||
|
if err != nil || d <= 0 || d > MostTokenLife {
|
||||||
|
return 0, fmt.Errorf("delivery: a token lasts a duration up to %s, such as 1h, not %q", MostTokenLife, expires)
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnrolmentExpiry is what enrolment-expired carries (§2): the node, and the enrolment-issued event that carried the
|
||||||
|
// key's content, named by its message id (the bundle's publish answers no stream sequence, and §2 asks only that the
|
||||||
|
// event be named), since the bus deleted the key before the holder heard of it.
|
||||||
|
type EnrolmentExpiry struct {
|
||||||
|
V int `json:"v"`
|
||||||
|
Node string `json:"node"`
|
||||||
|
IssuedID string `json:"issued-event"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x EnrolmentExpiry) check() error {
|
||||||
|
if err := checkName("node", x.Node); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(x.IssuedID) == "" {
|
||||||
|
return fmt.Errorf("delivery: the expiry of %s's enrolment names no enrolment-issued event", x.Node)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeEnrolmentExpiry is x as JSON at Version; it refuses what DecodeEnrolmentExpiry refuses.
|
||||||
|
func EncodeEnrolmentExpiry(x EnrolmentExpiry) ([]byte, error) {
|
||||||
|
if err := x.check(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
x.V = Version
|
||||||
|
return json.Marshal(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeEnrolmentExpiry reads enrolment-expired's body; it refuses an unknown version, a node that is not a name and
|
||||||
|
// no issued event named.
|
||||||
|
func DecodeEnrolmentExpiry(data []byte) (EnrolmentExpiry, error) {
|
||||||
|
var x EnrolmentExpiry
|
||||||
|
if err := decode(data, &x); err != nil {
|
||||||
|
return EnrolmentExpiry{}, err
|
||||||
|
}
|
||||||
|
if err := x.check(); err != nil {
|
||||||
|
return EnrolmentExpiry{}, err
|
||||||
|
}
|
||||||
|
return x, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Redeem** (§6): what a joining node-engine sends, and what the holder answers. Shared by the holder and the engine,
|
||||||
|
// which live in two repositories.
|
||||||
|
|
||||||
|
// Redeem is the redeem verb's call: the node, the public halves of its keys and its identity key's signature over
|
||||||
|
// RedeemProof. No token: the bus's login check verified it.
|
||||||
|
type Redeem struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
PublicKey string `json:"public_key"`
|
||||||
|
OverlayKey string `json:"overlay_key,omitempty"`
|
||||||
|
SealingKey string `json:"sealing_key"`
|
||||||
|
ServingKey string `json:"serving_key,omitempty"`
|
||||||
|
Profile map[string]any `json:"profile,omitempty"`
|
||||||
|
Tunnel json.RawMessage `json:"tunnel,omitempty"`
|
||||||
|
Proof string `json:"proof"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedeemProof is the bytes a node signs with its identity key when it redeems: its name and every public key it
|
||||||
|
// presents, so a proof cannot be moved to another node or other keys. The holder builds the same bytes.
|
||||||
|
func RedeemProof(node, public, overlay, sealing, serving string) []byte {
|
||||||
|
return []byte("novox-mesh-redeem v1\x00" + node + "\x00" + public + "\x00" + overlay + "\x00" + sealing + "\x00" + serving)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeysDigest is the digest of the public keys a redeem presents, `sha256:<hex>`: what the key at redeeming and the
|
||||||
|
// events carry, the one hash an event carries (§1).
|
||||||
|
func KeysDigest(public, overlay, sealing, serving string) string {
|
||||||
|
sum := sha256.Sum256([]byte(strings.Join([]string{public, overlay, sealing, serving}, "\x00")))
|
||||||
|
return "sha256:" + hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeysDigest is r's keys' digest.
|
||||||
|
func (r Redeem) KeysDigest() string {
|
||||||
|
return KeysDigest(r.PublicKey, r.OverlayKey, r.SealingKey, r.ServingKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProofBytes is r's proof decoded from base64.
|
||||||
|
func (r Redeem) ProofBytes() ([]byte, error) { return base64.StdEncoding.DecodeString(r.Proof) }
|
||||||
|
|
||||||
|
// RedeemAnswer is what redeem answers (§6): the node, the login it dials as next, its account sealed to the sealing key it
|
||||||
|
// presented (base64), its certificate as PEM, the vault's public signing half (base64), and the vault's signature over
|
||||||
|
// SignedBytes under the context of KindEnrolment. The engine refuses an answer not signed by the key its token carries.
|
||||||
|
type RedeemAnswer struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
Login string `json:"login"`
|
||||||
|
Account string `json:"account"`
|
||||||
|
Certificate string `json:"certificate"`
|
||||||
|
Signer string `json:"signer"`
|
||||||
|
Signature string `json:"signature,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignedBytes is what the vault signs of a: a without its signature, as JSON.
|
||||||
|
func (a RedeemAnswer) SignedBytes() []byte {
|
||||||
|
a.Signature = ""
|
||||||
|
raw, _ := json.Marshal(a)
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// **The vault's enrolment verbs** (ADR 0561 §5, ADR 0587 §3, §5), asked by mesh-enrolment alone.
|
||||||
|
const (
|
||||||
|
EnrolmentTokenVerb = "enrolment-token"
|
||||||
|
NodeAccountVerb = "node-account"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnrolmentTokenAnswer is what enrolment-token answers: what the joining machine must trust (the bus's address, its
|
||||||
|
// certificate's fingerprint, the vault's public signing key in base64), never the token; Sealed holds the token sealed
|
||||||
|
// to the operator key when asked with sealed, relayed by tokens as it is; Dropped says a drop left neither the token
|
||||||
|
// nor its hash.
|
||||||
|
type EnrolmentTokenAnswer struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
Broker string `json:"broker,omitempty"`
|
||||||
|
Fingerprint string `json:"fingerprint,omitempty"`
|
||||||
|
Signer string `json:"signer,omitempty"`
|
||||||
|
Sealed string `json:"sealed,omitempty"`
|
||||||
|
Dropped bool `json:"dropped,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeAccountAnswer is what node-account answers: the login minted, the account sealed to the sealing key the node
|
||||||
|
// presented (base64), never in the clear; Retired says a retire left no account.
|
||||||
|
type NodeAccountAnswer struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
Login string `json:"login,omitempty"`
|
||||||
|
Account string `json:"account,omitempty"`
|
||||||
|
Retired bool `json:"retired,omitempty"`
|
||||||
|
}
|
||||||
@@ -356,17 +356,16 @@ func TestGateSame(t *testing.T) { // 1201/AC6
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGateBuckets(t *testing.T) {
|
// today's keys: names of letters, digits, - / and _ key exactly as before the escape.
|
||||||
|
func TestKeysOfPlainNamesAreUnchanged(t *testing.T) { // 1323/AC2
|
||||||
|
// And the buckets and their history are the decided layout (once TestGateBuckets; merged to pay for issue 774's
|
||||||
|
// token test).
|
||||||
if MergesBucket != "mesh-delivery-gate_merges" || AsksBucket != "mesh-delivery-gate_asks" ||
|
if MergesBucket != "mesh-delivery-gate_merges" || AsksBucket != "mesh-delivery-gate_asks" ||
|
||||||
OwedBucket != "mesh-delivery-gate_owed" || AnswersBucket != "mesh-delivery-teller_answers" ||
|
OwedBucket != "mesh-delivery-gate_owed" || AnswersBucket != "mesh-delivery-teller_answers" ||
|
||||||
MergesHistory != 10 || AsksHistory != 5 || OwedHistory != 5 || AnswersHistory != 5 ||
|
MergesHistory != 10 || AsksHistory != 5 || OwedHistory != 5 || AnswersHistory != 5 ||
|
||||||
TurnsBucket != "mesh-delivery-gate_turns" || TurnsHistory != 5 {
|
TurnsBucket != "mesh-delivery-gate_turns" || TurnsHistory != 5 {
|
||||||
t.Error("a bucket or history differs from the decided layout")
|
t.Error("a bucket or history differs from the decided layout")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// today's keys: names of letters, digits, - / and _ key exactly as before the escape.
|
|
||||||
func TestKeysOfPlainNamesAreUnchanged(t *testing.T) { // 1323/AC2
|
|
||||||
hh, _ := HeadsHash(gateHeads())
|
hh, _ := HeadsHash(gateHeads())
|
||||||
for _, name := range []string{"feat/x", "fix/1323-escape", "feat/delivery_source-keys", "main", "A/B_c-9"} {
|
for _, name := range []string{"feat/x", "fix/1323-escape", "feat/delivery_source-keys", "main", "A/B_c-9"} {
|
||||||
if k, err := MergesKey(name); err != nil || k != name {
|
if k, err := MergesKey(name); err != nil || k != name {
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// **A node's two logins** (novox/hq ADR 0583, issue 774). A node's bus account rotates between the login `node.<n>` and
|
||||||
|
// `node.<n>--b`, each with the node's permissions and its inbox `_INBOX.node.<n>` unchanged (§1). Which one a node uses
|
||||||
|
// is the vault's state `node-logins`, one key per node (§1, insight 2026-11-01), written by the vault alone: the login
|
||||||
|
// in use, the login rotating if any, and the rotation's stage. Each node-engine reads its own node's key alone; the
|
||||||
|
// grants part reads them all and composes both logins while a node rotates (§2). The key lives while the node does:
|
||||||
|
// its rotating part is cleared at rotation-done or abandon, and the key deleted when the node leaves placement.
|
||||||
|
const (
|
||||||
|
NodeLoginsState = "node-logins"
|
||||||
|
NodeLoginsBucket = VaultModule + "_" + NodeLoginsState
|
||||||
|
NodeLoginsRead = VaultModule + "." + NodeLoginsState
|
||||||
|
|
||||||
|
// The stages of a rotation (§2-§4): begun when the vault made the other login's value and wrote it rotating; ready
|
||||||
|
// when the bus node's have shows a list holding both, said node-account-ready; confirmed when the engine called
|
||||||
|
// node-account-confirm as the new login. Done (rotation-done) is no stage: the rotating part is cleared.
|
||||||
|
RotationBegun = "begun"
|
||||||
|
RotationReady = "ready"
|
||||||
|
RotationConfirmed = "confirmed"
|
||||||
|
|
||||||
|
// The vault's rotation verbs on its seat (§2, §3, §5).
|
||||||
|
RotateNodeVerb = "rotate-node"
|
||||||
|
NodeAccountConfirmVerb = "node-account-confirm"
|
||||||
|
AbandonVerb = "abandon"
|
||||||
|
|
||||||
|
// The vault's events of a rotation, said as its own (§2, §4), each carrying the node's NodeLogin.
|
||||||
|
RotationBegunEvent = "rotation-begun"
|
||||||
|
NodeAccountReadyEvent = "node-account-ready"
|
||||||
|
RotationDoneEvent = "rotation-done"
|
||||||
|
|
||||||
|
// NodeAccountFallbackFinding is what a node-engine says when it fell back to its old login after repeated
|
||||||
|
// authentication failures with the new one, before rotation-done; the watchman raises it (§6).
|
||||||
|
NodeAccountFallbackFinding = "node-account-fallback"
|
||||||
|
|
||||||
|
nodeLoginPrefix = "node."
|
||||||
|
// SecondLoginSuffix marks a node's second login, as a module's account rotates over `--b` (ADR 0114).
|
||||||
|
SecondLoginSuffix = "--b"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RotationEventSubject is where the vault says event of a rotation.
|
||||||
|
func RotationEventSubject(event string) string { return "mesh.mod." + VaultModule + ".event." + event }
|
||||||
|
|
||||||
|
// NodeLogin is one key of `node-logins`, the node's name: the login in use, the login rotating to (empty when none, and
|
||||||
|
// then no stage), the rotation's stage, since when the key says what it says, and the user list's revision that held
|
||||||
|
// both logins, from ready on.
|
||||||
|
type NodeLogin struct {
|
||||||
|
V int `json:"v"`
|
||||||
|
Node string `json:"node"`
|
||||||
|
Login string `json:"login"`
|
||||||
|
Rotating string `json:"rotating,omitempty"`
|
||||||
|
Stage string `json:"stage,omitempty"`
|
||||||
|
Since time.Time `json:"since"`
|
||||||
|
ListRevision uint64 `json:"list-revision,omitempty"`
|
||||||
|
// Acts are the hand-act ids that warranted the node's rotations, oldest first (ADR 0583 §2): the vault records each.
|
||||||
|
Acts []string `json:"acts,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FirstLogin is a node's first login, `node.<n>`, the one every node had before rotation; SecondLogin its other.
|
||||||
|
func FirstLogin(node string) string { return nodeLoginPrefix + node }
|
||||||
|
func SecondLogin(node string) string { return nodeLoginPrefix + node + SecondLoginSuffix }
|
||||||
|
|
||||||
|
// OtherLogin is the login a node rotates to from login: the second from the first, the first from the second.
|
||||||
|
func OtherLogin(node, login string) string {
|
||||||
|
if login == SecondLogin(node) {
|
||||||
|
return FirstLogin(node)
|
||||||
|
}
|
||||||
|
return SecondLogin(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginNode is the node a node login names, either of its two, and whether login is one.
|
||||||
|
func LoginNode(login string) (string, bool) {
|
||||||
|
rest, ok := strings.CutPrefix(login, nodeLoginPrefix)
|
||||||
|
rest = strings.TrimSuffix(rest, SecondLoginSuffix)
|
||||||
|
return rest, ok && checkName("node", rest) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotates says the node is rotating: a login rotating is named.
|
||||||
|
func (l NodeLogin) Rotates() bool { return l.Rotating != "" }
|
||||||
|
|
||||||
|
func (l NodeLogin) check() error {
|
||||||
|
if err := checkName("node", l.Node); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if l.Login != FirstLogin(l.Node) && l.Login != SecondLogin(l.Node) {
|
||||||
|
return fmt.Errorf("delivery: the node %s logs in as %q, neither of its two logins", l.Node, l.Login)
|
||||||
|
}
|
||||||
|
for _, act := range l.Acts {
|
||||||
|
if strings.TrimSpace(act) == "" {
|
||||||
|
return fmt.Errorf("delivery: the node %s names an empty hand act among its rotations' warrants", l.Node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !l.Rotates() {
|
||||||
|
if l.Stage != "" || l.ListRevision != 0 {
|
||||||
|
return fmt.Errorf("delivery: the node %s rotates to nothing and names stage %q", l.Node, l.Stage)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if l.Rotating != OtherLogin(l.Node, l.Login) {
|
||||||
|
return fmt.Errorf("delivery: the node %s rotates from %s to %q, not its other login", l.Node, l.Login, l.Rotating)
|
||||||
|
}
|
||||||
|
switch l.Stage {
|
||||||
|
case RotationBegun, RotationReady, RotationConfirmed:
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("delivery: the node %s's rotation is at stage %q, not begun, ready or confirmed", l.Node, l.Stage)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeNodeLogin is l as JSON at Version; it refuses what DecodeNodeLogin refuses.
|
||||||
|
func EncodeNodeLogin(l NodeLogin) ([]byte, error) {
|
||||||
|
if err := l.check(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
l.V = Version
|
||||||
|
return json.Marshal(l)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeNodeLogin reads a key of `node-logins`; it refuses an unknown version (ErrUnknownVersion), a login that is not
|
||||||
|
// one of the node's two, a rotation to anything but the other, and a stage without a rotation or an unknown one. An
|
||||||
|
// empty rotating reads as none.
|
||||||
|
func DecodeNodeLogin(data []byte) (NodeLogin, error) {
|
||||||
|
var l NodeLogin
|
||||||
|
if err := decode(data, &l); err != nil {
|
||||||
|
return NodeLogin{}, err
|
||||||
|
}
|
||||||
|
if err := l.check(); err != nil {
|
||||||
|
return NodeLogin{}, err
|
||||||
|
}
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
@@ -13,8 +13,8 @@ import (
|
|||||||
// what it says changed. The watchman (seat mesh-watchdogs) reads it for six alerts the controller raised from the
|
// what it says changed. The watchman (seat mesh-watchdogs) reads it for six alerts the controller raised from the
|
||||||
// passing health message: machine-network (Network), network-rewritten (the resolver file's part, Writer and Owner),
|
// passing health message: machine-network (Network), network-rewritten (the resolver file's part, Writer and Owner),
|
||||||
// network-unreachable (Peers, read across every node's report), machine-units (Units), relogin-needed (Relogin) and
|
// network-unreachable (Peers, read across every node's report), machine-units (Units), relogin-needed (Relogin) and
|
||||||
// directory-used-as-found (Found). Moved here from mesh-host internal/link (65fbc1e6) unchanged; the engine builds a
|
// directory-used-as-found (Found); and for node-account-fallback (Fallback, ADR 0583 §6). Moved here from mesh-host
|
||||||
// report from its own health statement there (NodeHealthOf).
|
// internal/link (65fbc1e6); the engine builds a report from its own health statement there (NodeHealthOf).
|
||||||
|
|
||||||
// NodeHealthState and NodeHealthBucket are the node-engine's state health, as a reader names it and as the bus does.
|
// NodeHealthState and NodeHealthBucket are the node-engine's state health, as a reader names it and as the bus does.
|
||||||
const (
|
const (
|
||||||
@@ -40,6 +40,10 @@ type NodeHealth struct {
|
|||||||
Relogin []NodeAccount `json:"relogin,omitempty"`
|
Relogin []NodeAccount `json:"relogin,omitempty"`
|
||||||
// Found are the directories used as found.
|
// Found are the directories used as found.
|
||||||
Found []NodeDirectory `json:"found,omitempty"`
|
Found []NodeDirectory `json:"found,omitempty"`
|
||||||
|
// Fallback is set when the engine fell back to its node's old login after the new one failed its test-dial or its
|
||||||
|
// confirm (novox/hq ADR 0583 §6): the watchman raises node-account-fallback from it. Nil while the node logs in as
|
||||||
|
// its login in force.
|
||||||
|
Fallback *LoginFallback `json:"fallback,omitempty"`
|
||||||
// Root are the engine's verdicts on each account a module declares never to become root without a person (novox/hq
|
// Root are the engine's verdicts on each account a module declares never to become root without a person (novox/hq
|
||||||
// ADR 0266), sorted: the agent account's among them where a module declares it. What the self-check's DA and D-root
|
// ADR 0266), sorted: the agent account's among them where a module declares it. What the self-check's DA and D-root
|
||||||
// judge an agent's confinement by, on the machine's own word.
|
// judge an agent's confinement by, on the machine's own word.
|
||||||
@@ -51,6 +55,15 @@ type NodeHealth struct {
|
|||||||
At time.Time `json:"at"`
|
At time.Time `json:"at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoginFallback is a node's fall back to its old login (ADR 0583 §6): the node, the login it fell back from (the one the
|
||||||
|
// rotation moved to), why, in words with no credential, and when.
|
||||||
|
type LoginFallback struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
From string `json:"from"`
|
||||||
|
Why string `json:"why"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
// NetworkHealth is the machine's networking (ADR 0241): the worst of its parts, since when, and each part.
|
// NetworkHealth is the machine's networking (ADR 0241): the worst of its parts, since when, and each part.
|
||||||
type NetworkHealth struct {
|
type NetworkHealth struct {
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
@@ -150,6 +163,9 @@ func EncodeNodeHealth(h NodeHealth) ([]byte, error) {
|
|||||||
if h.Node == "" {
|
if h.Node == "" {
|
||||||
return nil, errors.New("a node's health report names its node")
|
return nil, errors.New("a node's health report names its node")
|
||||||
}
|
}
|
||||||
|
if f := h.Fallback; f != nil && (f.Node != h.Node || f.From == "" || f.Why == "" || f.At.IsZero()) {
|
||||||
|
return nil, fmt.Errorf("a node's fall back to its old login names its node %s, the login fallen back from, why and when", h.Node)
|
||||||
|
}
|
||||||
return json.Marshal(h)
|
return json.Marshal(h)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package delivery
|
package delivery
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"reflect"
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||||
)
|
)
|
||||||
@@ -13,7 +16,7 @@ import (
|
|||||||
// check (ADR 0201 §13) refuses such a field in a state every node reads.
|
// check (ADR 0201 §13) refuses such a field in a state every node reads.
|
||||||
var secretNamed = regexp.MustCompile(`(?i)(secret|passw|token|api[-_]?key|private[-_]?key|credential|bearer|cookie|salt|signing)`)
|
var secretNamed = regexp.MustCompile(`(?i)(secret|passw|token|api[-_]?key|private[-_]?key|credential|bearer|cookie|salt|signing)`)
|
||||||
|
|
||||||
// Issue 1126 (ADR 0555): a node's record reads back, rides in its have key, refuses a node that is not a name and an empty capability, and
|
// Issue 1126 (ADR 0555), and issue 774 for the enrolment's keys (ADR 0587 §2): a node's record reads back, rides in its have key, refuses a node that is not a name and an empty capability, and
|
||||||
// neither it nor the placement file names a field like a secret.
|
// neither it nor the placement file names a field like a secret.
|
||||||
func TestNodeRecord(t *testing.T) {
|
func TestNodeRecord(t *testing.T) {
|
||||||
hub := NodeRecord{Node: "novox", TunnelKey: "k", Capabilities: []string{"docker"}, SealingKey: "age1x", ServingKey: "pub",
|
hub := NodeRecord{Node: "novox", TunnelKey: "k", Capabilities: []string{"docker"}, SealingKey: "age1x", ServingKey: "pub",
|
||||||
@@ -68,6 +71,143 @@ func TestNodeRecord(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
walk(reflect.TypeOf(NodeRecord{}), "NodeRecord")
|
walk(reflect.TypeOf(NodeRecord{}), "NodeRecord")
|
||||||
|
// 774/S35/AC1: an enrolment key and an address key read back equal, and neither names a field like a secret: the
|
||||||
|
// grants part reads them (ADR 0587 §2).
|
||||||
|
issued := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC)
|
||||||
|
// The tunnel key given at issue, at every stage.
|
||||||
|
wg := base64.StdEncoding.EncodeToString(make([]byte, 32))
|
||||||
|
for _, e := range []Enrolment{
|
||||||
|
{Node: "lab", Address: "10.0.0.7", Why: "a new machine", Caller: "mesh-enrolment", Ask: "a1", Stage: EnrolmentLive,
|
||||||
|
OverlayKey: wg, Issued: issued, Expires: issued.Add(DefaultTokenLife)},
|
||||||
|
{Node: "lab", Address: "10.0.0.7", Why: "a new machine", Caller: "mesh-enrolment", Ask: "a1", Stage: EnrolmentRedeeming,
|
||||||
|
OverlayKey: wg, Issued: issued, Expires: issued.Add(MostTokenLife), KeysDigest: KeysDigest("pub", "overlay", "sealing", "serving")},
|
||||||
|
} {
|
||||||
|
raw, err := EncodeEnrolment(e)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
back, err := DecodeEnrolment(raw)
|
||||||
|
e.V = Version
|
||||||
|
if err != nil || !reflect.DeepEqual(back, e) {
|
||||||
|
t.Errorf("enrolment round trip: %+v, %v", back, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ADR 0589 §6: reserved before the ask with neither ask nor issue time, and from the ask with both.
|
||||||
|
if raw, err := EncodeEnrolmentAddress("lab", "", time.Time{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if back, err := DecodeEnrolmentAddress(raw); err != nil || back.Node != "lab" || back.Ask != "" || strings.Contains(string(raw), "issued") {
|
||||||
|
t.Errorf("address round trip: %+v, %v (%s)", back, err, raw)
|
||||||
|
}
|
||||||
|
if raw, err := EncodeEnrolmentAddress("lab", "a1", issued); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if back, err := DecodeEnrolmentAddress(raw); err != nil || back.Ask != "a1" || !back.Issued.Equal(issued) {
|
||||||
|
t.Errorf("address at the ask round trip: %+v, %v", back, err)
|
||||||
|
}
|
||||||
|
for name, raw := range map[string]string{
|
||||||
|
"no node": `{"v":1}`,
|
||||||
|
"an ask with no issue time": `{"v":1,"node":"lab","ask":"a1"}`,
|
||||||
|
"an issue time with no ask": `{"v":1,"node":"lab","issued":"2026-11-02T01:00:00Z"}`,
|
||||||
|
} {
|
||||||
|
if _, err := DecodeEnrolmentAddress([]byte(raw)); err == nil {
|
||||||
|
t.Errorf("an address with %s: taken", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if id := EnrolmentIssuedID("lab", issued); id != "enrolment-issued.lab."+strconv.FormatInt(issued.UnixNano(), 10) {
|
||||||
|
t.Errorf("the issued event's id: %q", id)
|
||||||
|
}
|
||||||
|
for name, e := range map[string]Enrolment{
|
||||||
|
"keys before redeem": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentLive,
|
||||||
|
OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour), KeysDigest: KeysDigest("a", "b", "c", "d")},
|
||||||
|
"no keys at redeem": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentRedeeming,
|
||||||
|
OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour)},
|
||||||
|
"no address": {Node: "lab", Why: "w", Caller: "c", Stage: EnrolmentAsked, OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour)},
|
||||||
|
"no tunnel key": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentAsked, Issued: issued,
|
||||||
|
Expires: issued.Add(time.Hour)},
|
||||||
|
"a tunnel key not of 32 bytes": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentAsked,
|
||||||
|
OverlayKey: base64.StdEncoding.EncodeToString(make([]byte, 31)), Issued: issued, Expires: issued.Add(time.Hour)},
|
||||||
|
"other stage": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: "spent", OverlayKey: wg, Issued: issued,
|
||||||
|
Expires: issued.Add(time.Hour)},
|
||||||
|
} {
|
||||||
|
if _, err := EncodeEnrolment(e); err == nil {
|
||||||
|
t.Errorf("enrolment with %s: taken", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if key, ok := EnrolmentKeyAddress(EnrolmentAddressKey("10.0.0.7")); !ok || key != "10.0.0.7" {
|
||||||
|
t.Errorf("address key: %q %v", key, ok)
|
||||||
|
}
|
||||||
|
if node, ok := EnrolmentKeyNode(EnrolmentNodeKey("lab")); !ok || node != "lab" {
|
||||||
|
t.Errorf("node key: %q %v", node, ok)
|
||||||
|
}
|
||||||
|
for _, life := range []string{"", "1h", "24h"} {
|
||||||
|
if _, err := TokenLife(life); err != nil {
|
||||||
|
t.Errorf("a life of %q: %v", life, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, life := range []string{"25h", "-1h", "a day"} {
|
||||||
|
if _, err := TokenLife(life); err == nil {
|
||||||
|
t.Errorf("a life of %q: taken", life)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 774/S38/AC1: a node's key of node-logins reads back equal, an empty rotating reads as none, and a stage without a
|
||||||
|
// rotation, a rotation to anything but the other login, or a login not the node's is refused (ADR 0583 §1).
|
||||||
|
for _, l := range []NodeLogin{
|
||||||
|
{Node: "g14", Login: "node.g14", Since: issued},
|
||||||
|
{Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: RotationReady, Since: issued, ListRevision: 41},
|
||||||
|
{Node: "g14", Login: "node.g14--b", Rotating: "node.g14", Stage: RotationBegun, Since: issued},
|
||||||
|
} {
|
||||||
|
raw, err := EncodeNodeLogin(l)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
back, err := DecodeNodeLogin(raw)
|
||||||
|
l.V = Version
|
||||||
|
if err != nil || !reflect.DeepEqual(back, l) {
|
||||||
|
t.Errorf("node login round trip: %+v, %v", back, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The hand acts that warranted its rotations, as the vault records them.
|
||||||
|
if raw, err := EncodeNodeLogin(NodeLogin{Node: "g14", Login: "node.g14--b", Acts: []string{"act-1"}}); err != nil ||
|
||||||
|
!strings.Contains(string(raw), `"acts":["act-1"]`) {
|
||||||
|
t.Errorf("a login with its acts: %s, %v", raw, err)
|
||||||
|
}
|
||||||
|
if l, err := DecodeNodeLogin([]byte(`{"v":1,"node":"g14","login":"node.g14","rotating":"","since":"2026-11-02T01:00:00Z"}`)); err != nil || l.Rotates() {
|
||||||
|
t.Errorf("an empty rotating: %+v, %v", l, err)
|
||||||
|
}
|
||||||
|
for name, l := range map[string]NodeLogin{
|
||||||
|
"a stage, no rotation": {Node: "g14", Login: "node.g14", Stage: RotationBegun},
|
||||||
|
"another node's login": {Node: "g14", Login: "node.ace"},
|
||||||
|
"a rotation to itself": {Node: "g14", Login: "node.g14", Rotating: "node.g14", Stage: RotationBegun},
|
||||||
|
"a rotation, no stage": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b"},
|
||||||
|
"a rotation, done stage": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: "done"},
|
||||||
|
"an empty act": {Node: "g14", Login: "node.g14", Acts: []string{""}},
|
||||||
|
} {
|
||||||
|
if _, err := EncodeNodeLogin(l); err == nil {
|
||||||
|
t.Errorf("node login with %s: taken", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if node, ok := LoginNode("node.g14--b"); !ok || node != "g14" || OtherLogin("g14", "node.g14--b") != "node.g14" {
|
||||||
|
t.Errorf("the second login's node: %q %v", node, ok)
|
||||||
|
}
|
||||||
|
walk(reflect.TypeOf(NodeLogin{}), "NodeLogin")
|
||||||
|
walk(reflect.TypeOf(Enrolment{}), "Enrolment")
|
||||||
|
walk(reflect.TypeOf(EnrolmentAddress{}), "EnrolmentAddress")
|
||||||
|
walk(reflect.TypeOf(EnrolmentExpiry{}), "EnrolmentExpiry")
|
||||||
|
// enrolment-expired names the issued event by its message id (§2), never a stream sequence.
|
||||||
|
if raw, err := EncodeEnrolmentExpiry(EnrolmentExpiry{Node: "lab", IssuedID: "enrolment-issued.lab.1"}); err != nil {
|
||||||
|
t.Errorf("an expiry: %v", err)
|
||||||
|
} else if x, err := DecodeEnrolmentExpiry(raw); err != nil || x.IssuedID != "enrolment-issued.lab.1" ||
|
||||||
|
!strings.Contains(string(raw), `"issued-event":"enrolment-issued.lab.1"`) {
|
||||||
|
t.Errorf("an expiry reads back as %+v, %v (%s)", x, err, raw)
|
||||||
|
}
|
||||||
|
for name, raw := range map[string]string{
|
||||||
|
"no issued event": `{"v":1,"node":"lab"}`,
|
||||||
|
"a sequence for it": `{"v":1,"node":"lab","issued-event":7}`,
|
||||||
|
"a node not a name": `{"v":1,"node":"Lab Pi","issued-event":"e"}`,
|
||||||
|
} {
|
||||||
|
if _, err := DecodeEnrolmentExpiry([]byte(raw)); err == nil {
|
||||||
|
t.Errorf("an expiry with %s: taken", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
// And the placement file, which holds what a person decides of a node (K2): accounts by name, never a credential.
|
// And the placement file, which holds what a person decides of a node (K2): accounts by name, never a credential.
|
||||||
walk(reflect.TypeOf(Placement{}), "Placement")
|
walk(reflect.TypeOf(Placement{}), "Placement")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ const (
|
|||||||
OpDecide = "decide" // set a decided field of the node (NodeDecided by its JSON name), its address among them
|
OpDecide = "decide" // set a decided field of the node (NodeDecided by its JSON name), its address among them
|
||||||
OpConfig = "config" // set a module's config placement or values on the node
|
OpConfig = "config" // set a module's config placement or values on the node
|
||||||
OpSeed = "seed" // the node's whole placement: the take-over (adopt, §7), never given by a caller
|
OpSeed = "seed" // the node's whole placement: the take-over (adopt, §7), never given by a caller
|
||||||
|
// OpAddNode adds a joining node with its decided fields and the public halves of its keys, on the warrant of the
|
||||||
|
// enrolment that issued its token (novox/hq ADR 0587 §7): mesh-enrolment's alone, the proposal's one change.
|
||||||
|
OpAddNode = "add-node"
|
||||||
|
// OpRemoveNode takes a joining node off placement again when its enrolment ends unconfirmed, revoked or expired
|
||||||
|
// (the operator's decision of 2026-11-02 01:05): mesh-enrolment's alone, on the same warrant, the proposal's one
|
||||||
|
// change, of a node with no module and no holding. The holder deletes its key (RemovedNodes).
|
||||||
|
OpRemoveNode = "remove-node"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The states of an open proposal.
|
// The states of an open proposal.
|
||||||
@@ -107,6 +114,34 @@ type PlacementProposalChange struct {
|
|||||||
Values map[string]any `json:"values,omitempty"`
|
Values map[string]any `json:"values,omitempty"`
|
||||||
// Whole is the node's whole placement (OpSeed).
|
// Whole is the node's whole placement (OpSeed).
|
||||||
Whole *Placement `json:"whole,omitempty"`
|
Whole *Placement `json:"whole,omitempty"`
|
||||||
|
// Decided and Keys are a joining node's (OpAddNode): its decided fields, the address the operator approved among
|
||||||
|
// them, and the public halves of its keys: at the operator's yes its tunnel key alone, given at issue (the operator's
|
||||||
|
// decision of 2026-11-02 01:05); the identity keys are bound at redeem, checked against the node account's digest.
|
||||||
|
Decided *NodeDecided `json:"decided,omitempty"`
|
||||||
|
Keys *JoiningKeys `json:"keys,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JoiningKeys are the public halves of a joining node's keys (ADR 0587 §6, §7): its private network (tunnel) key, given
|
||||||
|
// at issue, and its identity, sealing and serving keys, presented at redeem, as Redeem carries them. An add-node at the
|
||||||
|
// operator's yes carries the tunnel key alone. Public halves alone, never a private key.
|
||||||
|
type JoiningKeys struct {
|
||||||
|
Identity string `json:"identity"`
|
||||||
|
Overlay string `json:"overlay,omitempty"`
|
||||||
|
Sealing string `json:"sealing"`
|
||||||
|
Serving string `json:"serving,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Digest is the keys' digest, as the enrolment's key holds it (KeysDigest).
|
||||||
|
func (k JoiningKeys) Digest() string { return KeysDigest(k.Identity, k.Overlay, k.Sealing, k.Serving) }
|
||||||
|
|
||||||
|
// AddNodeWarrant is the warrant an add-node carries (ADR 0587 §7): the enrolment's node and address as its issue's ask
|
||||||
|
// was bound to them, the ask's id, and the warrant's id as the teller wrote it. mesh-placement checks it names the
|
||||||
|
// change's node and address, and that the caller is mesh-enrolment as the runtime proves it (AddNodeRefusal).
|
||||||
|
type AddNodeWarrant struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Ask string `json:"ask"`
|
||||||
|
Warrant string `json:"warrant"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c PlacementProposalChange) check() error {
|
func (c PlacementProposalChange) check() error {
|
||||||
@@ -135,8 +170,28 @@ func (c PlacementProposalChange) check() error {
|
|||||||
return fmt.Errorf("delivery: the seed of %s names no placement of that node", c.Node)
|
return fmt.Errorf("delivery: the seed of %s names no placement of that node", c.Node)
|
||||||
}
|
}
|
||||||
return c.Whole.check()
|
return c.Whole.check()
|
||||||
|
case OpAddNode:
|
||||||
|
if c.Decided == nil || c.Decided.Address == "" || c.Keys == nil || c.Keys.Overlay == "" {
|
||||||
|
return fmt.Errorf("delivery: the add-node of %s names no address or no tunnel key", c.Node)
|
||||||
|
}
|
||||||
|
// The identity keys come at redeem, whole or not at all: an identity with no sealing key, or the reverse.
|
||||||
|
if (c.Keys.Identity == "") != (c.Keys.Sealing == "") || (c.Keys.Identity == "" && c.Keys.Serving != "") {
|
||||||
|
return fmt.Errorf("delivery: the add-node of %s names part of its identity keys; they come whole at redeem", c.Node)
|
||||||
|
}
|
||||||
|
if c.Decided.Hub {
|
||||||
|
return fmt.Errorf("delivery: the add-node of %s makes it the hub, which a joining node never is", c.Node)
|
||||||
|
}
|
||||||
|
if c.Module != "" || len(c.Ports)+len(c.Bindings) > 0 || c.Whole != nil {
|
||||||
|
return fmt.Errorf("delivery: the add-node of %s places a module; a module is placed by a proposal of its own", c.Node)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case OpRemoveNode:
|
||||||
|
if c.Module != "" || c.Decided != nil || c.Keys != nil || c.Whole != nil {
|
||||||
|
return fmt.Errorf("delivery: the remove-node of %s names more than its node", c.Node)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("delivery: %q is no change of placement: place, take-off, pin, unpin, hold, unhold, decide or config", c.Op)
|
return fmt.Errorf("delivery: %q is no change of placement: place, take-off, pin, unpin, hold, unhold, decide, config, add-node or remove-node", c.Op)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LayChanges is the placements of the nodes the changes name with the changes laid over current, in the order given;
|
// LayChanges is the placements of the nodes the changes name with the changes laid over current, in the order given;
|
||||||
@@ -175,6 +230,23 @@ func LayChanges(current map[string]Placement, changes []PlacementProposalChange)
|
|||||||
switch c.Op {
|
switch c.Op {
|
||||||
case OpSeed:
|
case OpSeed:
|
||||||
p = clonePlacement(*c.Whole)
|
p = clonePlacement(*c.Whole)
|
||||||
|
case OpAddNode:
|
||||||
|
// A joining node has no key yet (ADR 0587 §7): one that has is no joining node.
|
||||||
|
if _, has := current[c.Node]; has {
|
||||||
|
findings = append(findings, fmt.Sprintf("change %d: %s is placed already, so it does not join", i+1, c.Node))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p = Placement{V: Version, Node: c.Node, Decided: *c.Decided, Modules: []PlacedModule{}}
|
||||||
|
case OpRemoveNode:
|
||||||
|
// Only a joining node never confirmed goes this way: one with no module and no holding (a node that runs
|
||||||
|
// anything is taken off module by module, by a proposal the operator approves).
|
||||||
|
cur, has := current[c.Node]
|
||||||
|
if !has {
|
||||||
|
findings = append(findings, fmt.Sprintf("change %d: %s is not placed, so there is nothing to remove", i+1, c.Node))
|
||||||
|
} else if len(cur.Modules)+len(cur.Holds) > 0 {
|
||||||
|
findings = append(findings, fmt.Sprintf("change %d: %s runs modules or holds seats, so it is no joining node to remove", i+1, c.Node))
|
||||||
|
}
|
||||||
|
continue
|
||||||
case OpPlace:
|
case OpPlace:
|
||||||
m := PlacedModule{Module: c.Module, Ports: c.Ports, Bindings: c.Bindings}
|
m := PlacedModule{Module: c.Module, Ports: c.Ports, Bindings: c.Bindings}
|
||||||
if at >= 0 {
|
if at >= 0 {
|
||||||
@@ -378,6 +450,9 @@ type PlacementProposal struct {
|
|||||||
// Again is when `again` asked the holder to judge it now and raise its ask again (§10); the holder does once per
|
// Again is when `again` asked the holder to judge it now and raise its ask again (§10); the holder does once per
|
||||||
// value.
|
// value.
|
||||||
Again time.Time `json:"again,omitempty"`
|
Again time.Time `json:"again,omitempty"`
|
||||||
|
// Warrant is the enrolment's warrant an add-node carries (ADR 0587 §7): its Approve is the issue's, so the holder
|
||||||
|
// writes it with no ask of its own. Only an add-node carries one.
|
||||||
|
Warrant *AddNodeWarrant `json:"warrant,omitempty"`
|
||||||
// Withdrawn is who withdrew it and why (`withdraw`, §2): the holder then deletes it and its candidates and says
|
// Withdrawn is who withdrew it and why (`withdraw`, §2): the holder then deletes it and its candidates and says
|
||||||
// placement-withdrawn, which only the holder's process can say on its stream.
|
// placement-withdrawn, which only the holder's process can say on its stream.
|
||||||
Withdrawn string `json:"withdrawn,omitempty"`
|
Withdrawn string `json:"withdrawn,omitempty"`
|
||||||
@@ -419,9 +494,69 @@ func (p PlacementProposal) check() error {
|
|||||||
if p.Seed && p.ExportDigest == "" {
|
if p.Seed && p.ExportDigest == "" {
|
||||||
return fmt.Errorf("delivery: the seed %s names no export digest", p.ID)
|
return fmt.Errorf("delivery: the seed %s names no export digest", p.ID)
|
||||||
}
|
}
|
||||||
|
return p.checkAddNode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAddNode refuses an add-node or a remove-node that is not the proposal's one change, carries no warrant, or whose
|
||||||
|
// warrant names another node (or, for an add-node, another address) than the change; and a warrant on any other
|
||||||
|
// proposal (ADR 0587 §7).
|
||||||
|
func (p PlacementProposal) checkAddNode() error {
|
||||||
|
adds := 0
|
||||||
|
for _, c := range p.Changes {
|
||||||
|
if c.Op == OpAddNode || c.Op == OpRemoveNode {
|
||||||
|
adds++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if adds == 0 {
|
||||||
|
if p.Warrant != nil {
|
||||||
|
return fmt.Errorf("delivery: the proposal %s carries an enrolment's warrant and adds no node", p.ID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if adds != 1 || len(p.Changes) != 1 || p.HandBack || p.Seed {
|
||||||
|
return fmt.Errorf("delivery: the proposal %s adds a node beside other changes; an add-node is a proposal's one change", p.ID)
|
||||||
|
}
|
||||||
|
c, w := p.Changes[0], p.Warrant
|
||||||
|
if w == nil || w.Ask == "" || w.Warrant == "" {
|
||||||
|
return fmt.Errorf("delivery: the add-node of %s carries no warrant of its enrolment", c.Node)
|
||||||
|
}
|
||||||
|
if c.Op == OpRemoveNode {
|
||||||
|
if w.Node != c.Node {
|
||||||
|
return fmt.Errorf("delivery: the remove-node of %s carries the warrant of %s", c.Node, w.Node)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if w.Node != c.Node || w.Address != c.Decided.Address {
|
||||||
|
return fmt.Errorf("delivery: the add-node of %s at %s carries the warrant of %s at %s", c.Node, c.Decided.Address,
|
||||||
|
w.Node, w.Address)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemovedNodes are the nodes whose key a proposal deletes (its remove-node), which LayChanges leaves out of what it lays.
|
||||||
|
func (p PlacementProposal) RemovedNodes() []string {
|
||||||
|
var out []string
|
||||||
|
for _, c := range p.Changes {
|
||||||
|
if c.Op == OpRemoveNode {
|
||||||
|
out = append(out, c.Node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddNodeRefusal is why the holder of mesh-placement refuses an add-node or a remove-node from caller, the module the runtime proved
|
||||||
|
// called (ADR 0587 §7), or "": only mesh-enrolment adds a node; what the proposal itself must hold, DecodePlacementProposal
|
||||||
|
// refuses.
|
||||||
|
func AddNodeRefusal(p PlacementProposal, caller string) string {
|
||||||
|
if err := p.checkAddNode(); err != nil {
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
if p.Warrant != nil && caller != EnrolmentModule {
|
||||||
|
return fmt.Sprintf("an add-node or a remove-node is mesh-enrolment's alone, and %s proposed it", caller)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// EncodePlacementProposal is p as JSON at Version; it refuses what DecodePlacementProposal refuses.
|
// EncodePlacementProposal is p as JSON at Version; it refuses what DecodePlacementProposal refuses.
|
||||||
func EncodePlacementProposal(p PlacementProposal) ([]byte, error) {
|
func EncodePlacementProposal(p PlacementProposal) ([]byte, error) {
|
||||||
if err := p.check(); err != nil {
|
if err := p.check(); err != nil {
|
||||||
|
|||||||
@@ -689,6 +689,92 @@ func TestPlacement(t *testing.T) {
|
|||||||
if ChangesDigest(p) == ChangesDigest(seed) {
|
if ChangesDigest(p) == ChangesDigest(seed) {
|
||||||
t.Error("two proposals' changes digest alike")
|
t.Error("two proposals' changes digest alike")
|
||||||
}
|
}
|
||||||
|
// 774/S42/AC1 (ADR 0587 §7): an add-node carries its enrolment's warrant naming its node and address, as its one
|
||||||
|
// change, from mesh-enrolment alone; a warrant naming another node or address is refused.
|
||||||
|
// At the operator's yes it carries the tunnel key alone (the operator, 2026-11-02 01:05).
|
||||||
|
joining := PlacementProposalChange{Op: OpAddNode, Node: "lab", Decided: &NodeDecided{Address: "10.0.0.7"},
|
||||||
|
Keys: &JoiningKeys{Overlay: "wg"}}
|
||||||
|
add := PlacementProposal{ID: id, By: "ace.mesh-enrolment", Why: "lab joins", At: time.Unix(1, 0).UTC(),
|
||||||
|
Changes: []PlacementProposalChange{joining}, Base: map[string]uint64{"lab": 0}, State: ProposalAnswering,
|
||||||
|
Warrant: &AddNodeWarrant{Node: "lab", Address: "10.0.0.7", Ask: "a-1", Warrant: "w-1"}}
|
||||||
|
if raw, err := EncodePlacementProposal(add); err != nil {
|
||||||
|
t.Errorf("an add-node: %v", err)
|
||||||
|
} else if back, err := DecodePlacementProposal(raw); err != nil || back.Warrant == nil || back.Changes[0].Keys.Overlay != "wg" {
|
||||||
|
t.Errorf("an add-node round trip: %+v, %v", back, err)
|
||||||
|
}
|
||||||
|
if AddNodeRefusal(add, EnrolmentModule) != "" || !strings.Contains(AddNodeRefusal(add, AssignerModule), "mesh-enrolment's alone") {
|
||||||
|
t.Errorf("add-node by caller: %q, %q", AddNodeRefusal(add, EnrolmentModule), AddNodeRefusal(add, AssignerModule))
|
||||||
|
}
|
||||||
|
if laid, f := LayChanges(current, add.Changes); len(f) != 0 || laid["lab"].Decided.Address != "10.0.0.7" || len(laid["lab"].Modules) != 0 {
|
||||||
|
t.Errorf("an add-node laid: %+v, %v", laid, f)
|
||||||
|
}
|
||||||
|
if _, f := LayChanges(current, []PlacementProposalChange{{Op: OpAddNode, Node: "g14", Decided: &NodeDecided{Address: "10.0.0.9"},
|
||||||
|
Keys: &JoiningKeys{Overlay: "wg"}}}); len(f) != 1 || !strings.Contains(f[0], "placed already") {
|
||||||
|
t.Errorf("an add-node of a placed node: %v", f)
|
||||||
|
}
|
||||||
|
for name, mut := range map[string]func(*PlacementProposal){
|
||||||
|
"warrant of another node": func(p *PlacementProposal) {
|
||||||
|
p.Warrant = &AddNodeWarrant{Node: "pi", Address: "10.0.0.7", Ask: "a", Warrant: "w"}
|
||||||
|
},
|
||||||
|
"warrant of another address": func(p *PlacementProposal) {
|
||||||
|
p.Warrant = &AddNodeWarrant{Node: "lab", Address: "10.0.0.8", Ask: "a", Warrant: "w"}
|
||||||
|
},
|
||||||
|
"no warrant": func(p *PlacementProposal) { p.Warrant = nil },
|
||||||
|
"no tunnel key": func(p *PlacementProposal) {
|
||||||
|
c := joining
|
||||||
|
c.Keys = &JoiningKeys{Identity: "id", Sealing: "seal"}
|
||||||
|
p.Changes = []PlacementProposalChange{c}
|
||||||
|
},
|
||||||
|
"half its identity keys": func(p *PlacementProposal) {
|
||||||
|
c := joining
|
||||||
|
c.Keys = &JoiningKeys{Overlay: "wg", Identity: "id"}
|
||||||
|
p.Changes = []PlacementProposalChange{c}
|
||||||
|
},
|
||||||
|
"beside another change": func(p *PlacementProposal) {
|
||||||
|
p.Changes = append(p.Changes, PlacementProposalChange{Op: OpPlace, Node: "lab", Module: "zsh"})
|
||||||
|
},
|
||||||
|
"the hub": func(p *PlacementProposal) {
|
||||||
|
c := joining
|
||||||
|
c.Decided = &NodeDecided{Address: "10.0.0.7", Hub: true}
|
||||||
|
p.Changes = []PlacementProposalChange{c}
|
||||||
|
},
|
||||||
|
"a warrant on a move": func(p *PlacementProposal) {
|
||||||
|
p.Changes, p.Base = []PlacementProposalChange{{Op: OpTakeOff, Node: "g14", Module: "zsh"}}, map[string]uint64{"g14": 4}
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
q := add
|
||||||
|
q.Changes = append([]PlacementProposalChange(nil), add.Changes...)
|
||||||
|
mut(&q)
|
||||||
|
if _, err := EncodePlacementProposal(q); err == nil {
|
||||||
|
t.Errorf("an add-node with %s: taken", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// With its identity keys whole, as bound at redeem, it is taken too.
|
||||||
|
whole := add
|
||||||
|
whole.Changes = []PlacementProposalChange{joining}
|
||||||
|
whole.Changes[0].Keys = &JoiningKeys{Overlay: "wg", Identity: "id", Sealing: "seal"}
|
||||||
|
if _, err := EncodePlacementProposal(whole); err != nil {
|
||||||
|
t.Errorf("an add-node with its identity keys: %v", err)
|
||||||
|
}
|
||||||
|
// An enrolment's end takes its never-confirmed node off again: mesh-enrolment's alone, of a node running nothing.
|
||||||
|
remove := add
|
||||||
|
remove.Changes = []PlacementProposalChange{{Op: OpRemoveNode, Node: "lab"}}
|
||||||
|
if _, err := EncodePlacementProposal(remove); err != nil || AddNodeRefusal(remove, AssignerModule) == "" ||
|
||||||
|
AddNodeRefusal(remove, EnrolmentModule) != "" || !slices.Equal(remove.RemovedNodes(), []string{"lab"}) {
|
||||||
|
t.Errorf("a remove-node: %v, %q", err, AddNodeRefusal(remove, AssignerModule))
|
||||||
|
}
|
||||||
|
joined := map[string]Placement{"lab": {V: Version, Node: "lab", Decided: NodeDecided{Address: "10.0.0.7"}, Modules: []PlacedModule{}}}
|
||||||
|
if laid, f := LayChanges(joined, remove.Changes); len(f) != 0 || len(laid) != 0 {
|
||||||
|
t.Errorf("a joining node removed: %v, %v", laid, f)
|
||||||
|
}
|
||||||
|
if _, f := LayChanges(current, []PlacementProposalChange{{Op: OpRemoveNode, Node: "g14"}}); len(f) != 1 ||
|
||||||
|
!strings.Contains(f[0], "runs modules") {
|
||||||
|
t.Errorf("a running node removed: %v", f)
|
||||||
|
}
|
||||||
|
remove.Warrant = &AddNodeWarrant{Node: "pi", Address: "10.0.0.7", Ask: "a", Warrant: "w"}
|
||||||
|
if _, err := EncodePlacementProposal(remove); err == nil {
|
||||||
|
t.Error("a remove-node on another node's warrant: taken")
|
||||||
|
}
|
||||||
for name, mut := range map[string]func(*PlacementProposal){
|
for name, mut := range map[string]func(*PlacementProposal){
|
||||||
"no id": func(p *PlacementProposal) { p.ID = "" },
|
"no id": func(p *PlacementProposal) { p.ID = "" },
|
||||||
"no proposer": func(p *PlacementProposal) { p.By = "" },
|
"no proposer": func(p *PlacementProposal) { p.By = "" },
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ const (
|
|||||||
|
|
||||||
// KindNodeCertificate is a node's certificate for its internal name, asked by that node's node-engine alone.
|
// KindNodeCertificate is a node's certificate for its internal name, asked by that node's node-engine alone.
|
||||||
KindNodeCertificate = "node-certificate"
|
KindNodeCertificate = "node-certificate"
|
||||||
// KindEnrolment is an enrolment reply, signed under its own context; no caller is named until adoption's record
|
// KindEnrolment is a redeem answer (RedeemAnswer), signed under its own context for mesh-enrolment alone (ADR 0587
|
||||||
// (ADR 0561 §5).
|
// §3, §6).
|
||||||
KindEnrolment = "enrolment"
|
KindEnrolment = "enrolment"
|
||||||
// KindAuthority is the vault's own authority certificate, which the vault signs for itself.
|
// KindAuthority is the vault's own authority certificate, which the vault signs for itself.
|
||||||
KindAuthority = "authority"
|
KindAuthority = "authority"
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Token is what a person carries to a machine that is joining (novox/hq ADR 0004): where the bus is, what certificate
|
||||||
|
// to expect there, whose signature to believe afterwards, and a one-time right to join; and, when it was issued for the
|
||||||
|
// machine's tunnel key, the machine's first tunnel (ADR 0169).
|
||||||
|
//
|
||||||
|
// Moved here from the node-engine (mesh-host internal/identity, which keeps an alias) so the vault, which makes the
|
||||||
|
// token from the lease write on (novox/hq ADR 0587 §3, issue 774), mesh-cli, which opens and prints it, and the
|
||||||
|
// node-engine, which parses it, read one shape. The field names are the wire format the controller's `token issue`
|
||||||
|
// writes, unchanged, so a token of either issuer reads the same; the tunnel stays in it (the lead's settlement of
|
||||||
|
// ADR 0587 §3, 2026-11-02).
|
||||||
|
type Token struct {
|
||||||
|
Version int `json:"v"`
|
||||||
|
|
||||||
|
// Node is what the mesh calls this machine: the bus user it dials as is named after it, so it must be known before
|
||||||
|
// the mesh can say anything.
|
||||||
|
Node string `json:"node,omitempty"`
|
||||||
|
|
||||||
|
Broker string `json:"broker,omitempty"`
|
||||||
|
Fingerprint string `json:"fingerprint,omitempty"`
|
||||||
|
Signer []byte `json:"signer,omitempty"`
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
|
||||||
|
// Adopted says this node joins adopted (novox/hq ADR 0100); absent for a converged node.
|
||||||
|
Adopted bool `json:"adopted,omitempty"`
|
||||||
|
|
||||||
|
// Tunnel is this machine's first tunnel, when the token was issued for the key it made (novox/hq ADR 0169): its own
|
||||||
|
// address and the hub to reach, so it reaches the bus over the private network and the bus never faces the internet.
|
||||||
|
Tunnel *TokenTunnel `json:"tunnel,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenTunnel is the joining machine's side of its first tunnel, in the field names the controller writes.
|
||||||
|
type TokenTunnel struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Range string `json:"range"`
|
||||||
|
HubKey string `json:"hub_key"`
|
||||||
|
HubEndpoint string `json:"hub_endpoint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseToken reads a token a person pasted. Every refusal says *this is not a token* rather than *this is the wrong
|
||||||
|
// token*: telling "not from the mesh I joined" apart from "malformed" is a signature check later (ADR 0004). A token
|
||||||
|
// missing a part is refused whole, naming every part, never used partially: without the fingerprint a node connects
|
||||||
|
// to whatever answers, without the signing key it cannot tell a declaration from a forgery.
|
||||||
|
func ParseToken(encoded string) (Token, error) {
|
||||||
|
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(encoded))
|
||||||
|
if err != nil {
|
||||||
|
return Token{}, fmt.Errorf("this is not a token: %w", err)
|
||||||
|
}
|
||||||
|
var t Token
|
||||||
|
if err := json.Unmarshal(raw, &t); err != nil {
|
||||||
|
return Token{}, fmt.Errorf("this is not a token: %w", err)
|
||||||
|
}
|
||||||
|
if t.Version != 1 {
|
||||||
|
return Token{}, fmt.Errorf(
|
||||||
|
"this token says it is version %d, and this host understands version 1", t.Version)
|
||||||
|
}
|
||||||
|
if missing := t.Missing(); len(missing) > 0 {
|
||||||
|
return Token{}, fmt.Errorf(
|
||||||
|
"this token is missing %s, so it cannot be used to join anything",
|
||||||
|
strings.Join(missing, ", "))
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing names every part of t a joining machine needs and t lacks.
|
||||||
|
func (t Token) Missing() []string {
|
||||||
|
var missing []string
|
||||||
|
if strings.TrimSpace(t.Broker) == "" {
|
||||||
|
missing = append(missing, "the broker's address")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(t.Fingerprint) == "" {
|
||||||
|
missing = append(missing, "the broker certificate's fingerprint")
|
||||||
|
}
|
||||||
|
if len(t.Signer) != ed25519.PublicKeySize {
|
||||||
|
missing = append(missing, "the vault's signing key")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(t.Secret) == "" {
|
||||||
|
missing = append(missing, "the one-time secret")
|
||||||
|
}
|
||||||
|
if tt := t.Tunnel; tt != nil {
|
||||||
|
for _, part := range []struct{ value, says string }{
|
||||||
|
{tt.Key, "the tunnel key it was issued for"}, {tt.Address, "this machine's address"},
|
||||||
|
{tt.Range, "the private network's range"}, {tt.HubKey, "the hub's tunnel key"},
|
||||||
|
{tt.HubEndpoint, "where the hub's tunnel is dialled"},
|
||||||
|
} {
|
||||||
|
if strings.TrimSpace(part.value) == "" {
|
||||||
|
missing = append(missing, part.says)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode is t as one line a person carries: base64 (URL alphabet, no padding) of its JSON at version 1, as the
|
||||||
|
// controller's `token issue` writes it. Refused when a part is missing.
|
||||||
|
func (t Token) Encode() (string, error) {
|
||||||
|
t.Version = 1
|
||||||
|
if missing := t.Missing(); len(missing) > 0 {
|
||||||
|
return "", fmt.Errorf("delivery: the token is missing %s", strings.Join(missing, ", "))
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(t)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignerKey is the signing key the token carries, as a key: the controller's before the lease write, the vault's after.
|
||||||
|
func (t Token) SignerKey() ed25519.PublicKey { return ed25519.PublicKey(t.Signer) }
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// controllerToken is a token made by the controller's own `token issue` code: mesh-control origin/main 5c281ad9,
|
||||||
|
// internal/token Token.Encode, run on 2026-11-02 for node anchor, adopted, with a first tunnel, its signer the ed25519
|
||||||
|
// key of the seed 00 01 .. 1f. Copied, never written by hand.
|
||||||
|
const controllerToken = "eyJ2IjoxLCJub2RlIjoiYW5jaG9yIiwiYnJva2VyIjoiMTkyLjAuMi4xMDo0MjIyIiwiZmluZ2VycHJpbnQiOiJzaGEyNTY6YWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYiIsInNpZ25lciI6IkE2RUh2L1BPRUw0ZGNOMFk1MHZBbVdmazFqQ2JwUTFmSGR5R1pCSlZNYmc9Iiwic2VjcmV0IjoiYS1vbmUtdGltZS1zZWNyZXQiLCJhZG9wdGVkIjp0cnVlLCJ0dW5uZWwiOnsia2V5IjoiZEdobExXMWhZMmhwYm1WekxXdGxlUT09IiwiYWRkcmVzcyI6IjEwLjAuMC43LzI0IiwicmFuZ2UiOiIxMC4wLjAuMC8yNCIsImh1Yl9rZXkiOiJkR2hsTFdoMVluTXRhMlY1IiwiaHViX2VuZHBvaW50IjoiMTk4LjUxLjEwMC4xOjUxODIwIn19"
|
||||||
|
|
||||||
|
// 774/S36/AC1: a token the controller made reads, with the moved code, field for field as it was written; encoding it
|
||||||
|
// again gives the same line; a part missing is refused naming it; garbage and another version are not tokens.
|
||||||
|
func TestATokenTheControllerMadeReads(t *testing.T) {
|
||||||
|
signer, _ := hex.DecodeString("03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8")
|
||||||
|
want := Token{Version: 1, Node: "anchor", Broker: "192.0.2.10:4222",
|
||||||
|
Fingerprint: "sha256:" + strings.Repeat("ab", 32), Signer: signer, Secret: "a-one-time-secret", Adopted: true,
|
||||||
|
Tunnel: &TokenTunnel{Key: "dGhlLW1hY2hpbmVzLWtleQ==", Address: "10.0.0.7/24", Range: "10.0.0.0/24",
|
||||||
|
HubKey: "dGhlLWh1YnMta2V5", HubEndpoint: "198.51.100.1:51820"}}
|
||||||
|
got, err := ParseToken(" " + controllerToken + "\n")
|
||||||
|
if err != nil || !reflect.DeepEqual(got, want) || len(got.SignerKey()) != 32 {
|
||||||
|
t.Fatalf("parsed %+v, %v", got, err)
|
||||||
|
}
|
||||||
|
if again, err := got.Encode(); err != nil || again != controllerToken {
|
||||||
|
t.Errorf("encoded again: %q, %v", again, err)
|
||||||
|
}
|
||||||
|
for _, c := range []struct {
|
||||||
|
cut func(*Token)
|
||||||
|
expect string
|
||||||
|
}{
|
||||||
|
{func(t *Token) { t.Broker = "" }, "broker's address"},
|
||||||
|
{func(t *Token) { t.Fingerprint = "" }, "fingerprint"},
|
||||||
|
{func(t *Token) { t.Signer = t.Signer[:5] }, "signing key"},
|
||||||
|
{func(t *Token) { t.Secret = "" }, "one-time secret"},
|
||||||
|
{func(t *Token) { t.Tunnel = &TokenTunnel{Key: "k"} }, "where the hub's tunnel is dialled"},
|
||||||
|
} {
|
||||||
|
cut := want
|
||||||
|
cut.Tunnel = &TokenTunnel{}
|
||||||
|
*cut.Tunnel = *want.Tunnel
|
||||||
|
c.cut(&cut)
|
||||||
|
if _, err := cut.Encode(); err == nil || !strings.Contains(err.Error(), c.expect) {
|
||||||
|
t.Errorf("a token without %s: %v", c.expect, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"", "!!!not base64!!!", "aGVsbG8", "eyJ2Ijo5OX0"} {
|
||||||
|
if _, err := ParseToken(bad); err == nil {
|
||||||
|
t.Errorf("%q parsed as a token", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user