The node agent writes what the machine observes of itself as its record in its own have key, only when it changed (issue 1126, ADR 0555 §3)
This commit is contained in:
@@ -1115,6 +1115,8 @@ func runLink(ctx context.Context, opts options) error {
|
||||
proof := &proving{statePath: opts.state, version: runningVersion(), say: say}
|
||||
queue := &link.Session{
|
||||
Membership: membership,
|
||||
// And the public halves and certificate its record says (novox/hq ADR 0555 §3), read at each facts report.
|
||||
Keys: recordKeys(opts.state),
|
||||
// And the first have this build writes is what proves it to the launcher (ADR 0539 §9, ADR 0227 §8).
|
||||
HaveWritten: proof.haveWritten,
|
||||
// And the operator's hand-over of a directory used as found, carried by the controller from the
|
||||
@@ -2131,3 +2133,25 @@ func newlyMissing(last map[string]bool, st liveness.Statement) ([]string, map[st
|
||||
}
|
||||
return missingModules(fresh), now
|
||||
}
|
||||
|
||||
// recordKeys reads the public halves of the node's keys and the certificate the mesh issued for its internal name from
|
||||
// its state directory (novox/hq ADR 0555 §3): what the node's record says of them. A key that cannot be read is left
|
||||
// empty, never invented; the certificate is there only after the first declaration that carried it.
|
||||
func recordKeys(state string) func() link.RecordKeys {
|
||||
return func() link.RecordKeys {
|
||||
var k link.RecordKeys
|
||||
if id, err := identity.Load(identity.Path(state)); err == nil {
|
||||
k.TunnelKey = id.Overlay.Public
|
||||
}
|
||||
if sk, err := identity.LoadSealingKey(identity.SealingKeyPath(state)); err == nil {
|
||||
k.SealingKey = sk.Public
|
||||
}
|
||||
if sv, err := identity.LoadServingKey(identity.ServingKeyPath(state)); err == nil {
|
||||
k.ServingKey = sv.Public
|
||||
}
|
||||
if cert, err := os.ReadFile(identity.CertificatePath(state)); err == nil {
|
||||
k.Certificate = string(cert)
|
||||
}
|
||||
return k
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ module github.com/novox/mesh-host
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026134601-17b7cc480d6d
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026172945-2b0689f6546f
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
golang.org/x/crypto v0.57.0
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026134601-17b7cc480d6d h1:7F/dFerm01wvuGLPgJ67/VfdcvDexc+BQEE3qzOrlu4=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026134601-17b7cc480d6d/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026172945-2b0689f6546f h1:c0N990TeglIZtCdGwpn+11iIqOe0VQaH1pAgdj+LaIk=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026172945-2b0689f6546f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
+20
-4
@@ -189,8 +189,7 @@ func (q *Session) noteApply(r Report) bool {
|
||||
if r.Health != nil {
|
||||
s.takeLook(r.Health)
|
||||
}
|
||||
n := nodeFacts(r)
|
||||
s.node = &n
|
||||
s.node = q.nodeFactsWithRecord(r, s.node)
|
||||
return true
|
||||
}
|
||||
s.mu.Lock()
|
||||
@@ -242,11 +241,28 @@ func (q *Session) noteApply(r Report) bool {
|
||||
if s.owed() {
|
||||
q.oweHealth()
|
||||
}
|
||||
n := nodeFacts(r)
|
||||
s.node = &n
|
||||
s.node = q.nodeFactsWithRecord(r, s.node)
|
||||
return true
|
||||
}
|
||||
|
||||
// nodeFactsWithRecord is the node's own facts of a report with its record (ADR 0555 §3): a report that says nothing of
|
||||
// the machine leaves the record last made standing, and one without a health statement keeps the contract last stated.
|
||||
func (q *Session) nodeFactsWithRecord(r Report, before *delivery.HaveNode) *delivery.HaveNode {
|
||||
n := nodeFacts(r)
|
||||
if q.Keys != nil {
|
||||
n.Record = recordOf(q.Membership.Node, r, q.Keys())
|
||||
}
|
||||
if before != nil && before.Record != nil {
|
||||
switch {
|
||||
case n.Record == nil:
|
||||
n.Record = before.Record
|
||||
case n.Record.Contract == 0:
|
||||
n.Record.Contract = before.Record.Contract
|
||||
}
|
||||
}
|
||||
return &n
|
||||
}
|
||||
|
||||
// noteHealth takes a look's health, and says whether any module's changed.
|
||||
func (q *Session) noteHealth(h Health) bool {
|
||||
s := &q.haves
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
@@ -259,6 +260,43 @@ func TestTheReportIsSentFirstAndTheNodeKeyCarriesTheNodesFacts(t *testing.T) {
|
||||
if !delivery.SameHaveNode(got, want) {
|
||||
t.Errorf("the node key holds %+v, want %+v", got, want)
|
||||
}
|
||||
|
||||
// 1126 (ADR 0555 §3): with its keys, the node key carries the node's record: the profile's present capabilities and
|
||||
// networks, the outward links, the health's contract and the public keys; a report that changes none of it writes
|
||||
// nothing, a changed fact writes the key once, and a report without a profile or health leaves them standing.
|
||||
keys := RecordKeys{TunnelKey: "tk", SealingKey: "sk", ServingKey: "pk", Certificate: "cert"}
|
||||
q.Keys = func() RecordKeys { return keys }
|
||||
rep.Profile = map[string]any{"capabilities": []any{map[string]any{"name": "systemd", "present": true},
|
||||
map[string]any{"name": "docker", "present": false}, map[string]any{"name": "apt", "present": true}},
|
||||
"machine": map[string]any{"networks": []any{map[string]any{"link": "eth0", "network": "192.0.2.0/24"}}}}
|
||||
rep.Outward = []string{"eth0"}
|
||||
rep.Health = &Health{Contract: NodeKeyContract}
|
||||
q.SayFacts(context.Background(), rep)
|
||||
if got, err = delivery.DecodeHaveNode(l.kv["n1.node"].value); err != nil || got.Record == nil {
|
||||
t.Fatalf("the node key holds no record: %+v, %v", got, err)
|
||||
}
|
||||
wantRecord := delivery.NodeRecord{V: delivery.Version, Node: "n1", TunnelKey: "tk", SealingKey: "sk", ServingKey: "pk",
|
||||
Certificate: "cert", Capabilities: []string{"apt", "systemd"}, OutwardLinks: []string{"eth0"}, NetworksSaid: true,
|
||||
Networks: []catalogue.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}}, Contract: NodeKeyContract}
|
||||
if !delivery.SameNodeRecord(*got.Record, wantRecord) {
|
||||
t.Errorf("record = %+v, want %+v", *got.Record, wantRecord)
|
||||
}
|
||||
at := l.kv["n1.node"].rev
|
||||
q.SayFacts(context.Background(), rep)
|
||||
if l.kv["n1.node"].rev != at {
|
||||
t.Errorf("a report changing nothing wrote the node key again")
|
||||
}
|
||||
keys.Certificate = "cert-2"
|
||||
q.SayFacts(context.Background(), rep)
|
||||
if got, _ = delivery.DecodeHaveNode(l.kv["n1.node"].value); l.kv["n1.node"].rev != at+1 || got.Record.Certificate != "cert-2" {
|
||||
t.Errorf("a new certificate: revision %d (was %d), record %+v", l.kv["n1.node"].rev, at, got.Record)
|
||||
}
|
||||
bare := rep
|
||||
bare.Health = nil
|
||||
q.SayFacts(context.Background(), bare)
|
||||
if got, _ = delivery.DecodeHaveNode(l.kv["n1.node"].value); got.Record.Contract != NodeKeyContract {
|
||||
t.Errorf("a report without a health statement dropped the contract: %+v", got.Record)
|
||||
}
|
||||
}
|
||||
|
||||
// 1132/AC7: a write the bucket refuses is said in the log; the report went out, and the next apply writes again.
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package link
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// The node's record (novox/hq ADR 0555 §3, issue 1126): what the machine observes of itself, written in its own have key
|
||||
// `<node>.node` (delivery.HaveNode.Record) with its facts, so every node and the parts that compose read it from the bus
|
||||
// and hear its change as the key's. Each value is the one the controller took from this node's enrolment and reports
|
||||
// until now (its profile's present capabilities and networks, its outward links, its health statement's contract, and
|
||||
// the public halves of the keys it enrolled with), read here from the same sources, never from the controller.
|
||||
|
||||
// RecordKeys are the public halves the node holds and the certificate the mesh issued for its internal name, as read
|
||||
// from its state directory at the time of a pass: the certificate arrives after enrolment, a rekey changes the tunnel key.
|
||||
type RecordKeys struct {
|
||||
TunnelKey string
|
||||
SealingKey string
|
||||
ServingKey string
|
||||
Certificate string
|
||||
}
|
||||
|
||||
// recordOf is the node's record from one facts report and its keys; nil for a report that carries no profile (one that
|
||||
// says nothing of the machine, such as a rekey), so the record last written stands.
|
||||
func recordOf(node string, r Report, keys RecordKeys) *delivery.NodeRecord {
|
||||
if r.Profile == nil {
|
||||
return nil
|
||||
}
|
||||
var p struct {
|
||||
Capabilities []struct {
|
||||
Name string `json:"name"`
|
||||
Present bool `json:"present"`
|
||||
} `json:"capabilities"`
|
||||
Machine *struct {
|
||||
Networks []catalogue.AttachedNetwork `json:"networks"`
|
||||
} `json:"machine"`
|
||||
}
|
||||
raw, err := json.Marshal(r.Profile)
|
||||
if err != nil || json.Unmarshal(raw, &p) != nil {
|
||||
return nil
|
||||
}
|
||||
rec := &delivery.NodeRecord{V: delivery.Version, Node: node, TunnelKey: keys.TunnelKey, SealingKey: keys.SealingKey,
|
||||
ServingKey: keys.ServingKey, Certificate: keys.Certificate}
|
||||
for _, c := range p.Capabilities {
|
||||
if c.Present && c.Name != "" {
|
||||
rec.Capabilities = append(rec.Capabilities, c.Name)
|
||||
}
|
||||
}
|
||||
sort.Strings(rec.Capabilities)
|
||||
if p.Machine != nil {
|
||||
rec.NetworksSaid = true
|
||||
rec.Networks = p.Machine.Networks
|
||||
}
|
||||
if len(r.Outward) > 0 {
|
||||
rec.OutwardLinks = append([]string(nil), r.Outward...)
|
||||
}
|
||||
if r.Health != nil {
|
||||
rec.Contract = r.Health.Contract
|
||||
}
|
||||
return rec
|
||||
}
|
||||
@@ -47,8 +47,11 @@ type Session struct {
|
||||
// MoveSecret moves one value sealed to this machine into the vault, asked by the controller during the move of
|
||||
// the secrets (design 57's V3, issue 676). Nil answers every such ask with a refusal.
|
||||
MoveSecret MoveSecretFunc
|
||||
Say Announce
|
||||
Timeout time.Duration
|
||||
// Keys reads the node's public keys and its certificate for its record (ADR 0555 §3), at each facts report; nil writes
|
||||
// no record.
|
||||
Keys func() RecordKeys
|
||||
Say Announce
|
||||
Timeout time.Duration
|
||||
|
||||
once sync.Once
|
||||
|
||||
|
||||
Reference in New Issue
Block a user