From 8be1c2ab0d414c7ce9f39d4d2bdde0cd40c519d8 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 18:32:54 +0100 Subject: [PATCH] =?UTF-8?q?The=20node=20agent=20writes=20what=20the=20mach?= =?UTF-8?q?ine=20observes=20of=20itself=20as=20its=20record=20in=20its=20o?= =?UTF-8?q?wn=20have=20key,=20only=20when=20it=20changed=20(issue=201126,?= =?UTF-8?q?=20ADR=200555=20=C2=A73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/mesh-host/main.go | 24 ++++++++++++++ go.mod | 2 +- go.sum | 2 ++ internal/link/have.go | 24 +++++++++++--- internal/link/have_test.go | 38 ++++++++++++++++++++++ internal/link/record.go | 64 ++++++++++++++++++++++++++++++++++++++ internal/link/session.go | 7 +++-- 7 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 internal/link/record.go diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index ade268e..f00c38d 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -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 + } +} diff --git a/go.mod b/go.mod index ac493ab..1e7eb50 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 0728d2e..25a1911 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/link/have.go b/internal/link/have.go index fa923fb..c994c16 100644 --- a/internal/link/have.go +++ b/internal/link/have.go @@ -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 diff --git a/internal/link/have_test.go b/internal/link/have_test.go index 6b36223..227ad3c 100644 --- a/internal/link/have_test.go +++ b/internal/link/have_test.go @@ -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. diff --git a/internal/link/record.go b/internal/link/record.go new file mode 100644 index 0000000..ea48667 --- /dev/null +++ b/internal/link/record.go @@ -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` (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 +} diff --git a/internal/link/session.go b/internal/link/session.go index b804033..31f8a5a 100644 --- a/internal/link/session.go +++ b/internal/link/session.go @@ -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