From f8afaefba09e077d42255d18e0ce7b7abeac7cfb Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 19:24:36 +0100 Subject: [PATCH] =?UTF-8?q?The=20node=20keeps=20the=20mesh's=20own=20certi?= =?UTF-8?q?ficate=20it=20applies,=20from=20its=20kept=20declaration=20at?= =?UTF-8?q?=20start,=20for=20its=20record;=20one=20agent=20test=20folded?= =?UTF-8?q?=20to=20hold=20the=20count=20(issue=201126,=20ADR=200555=20?= =?UTF-8?q?=C2=A73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/mesh-host/authority.go | 39 +++++++++++++++++++++++++++++++++ cmd/mesh-host/authority_test.go | 32 +++++++++++++++++++++++++++ cmd/mesh-host/main.go | 3 +++ cmd/mesh-host/nodeagent.go | 15 +++++++++++++ go.mod | 2 +- go.sum | 2 ++ internal/agent/agent_test.go | 3 ++- internal/link/record.go | 4 +++- 8 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 cmd/mesh-host/authority.go create mode 100644 cmd/mesh-host/authority_test.go diff --git a/cmd/mesh-host/authority.go b/cmd/mesh-host/authority.go new file mode 100644 index 0000000..25f0903 --- /dev/null +++ b/cmd/mesh-host/authority.go @@ -0,0 +1,39 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// authorityPath is where this engine keeps the mesh's own certificate as it last applied it (public): beside the +// certificate issued for the machine's name, read into its record (novox/hq ADR 0555 §3). +func authorityPath(state string) string { return filepath.Join(filepath.Dir(state), "authority.crt") } + +// keepAuthority keeps the mesh's own certificate a composition carries (the resource a module's certificate.authority +// asks for, catalogue.AuthorityID), so the record says the authority this machine holds. A composition carrying none +// changes nothing. +func keepAuthority(state string, c delivery.Composed) error { + for _, raw := range c.Resources { + var r struct { + ID string `json:"id"` + Content string `json:"content"` + } + if json.Unmarshal(raw, &r) != nil || r.Content == "" || !strings.HasSuffix(r.ID, "."+catalogue.AuthorityID()) { + continue + } + path := authorityPath(state) + if was, err := os.ReadFile(path); err == nil && string(was) == r.Content { + return nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, []byte(r.Content), 0o644) + } + return nil +} diff --git a/cmd/mesh-host/authority_test.go b/cmd/mesh-host/authority_test.go new file mode 100644 index 0000000..48ce708 --- /dev/null +++ b/cmd/mesh-host/authority_test.go @@ -0,0 +1,32 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// Issue 1126 (ADR 0555 §3): the mesh's own certificate a composition carries is kept for the machine's record, and the +// record's keys read it; a composition carrying none changes nothing. +func TestTheAppliedAuthorityIsKeptForTheRecord(t *testing.T) { + state := filepath.Join(t.TempDir(), "state", "state.json") + res := func(id, content string) json.RawMessage { + raw, _ := json.Marshal(map[string]any{"id": id, "type": "file", "path": "/etc/x", "content": content}) + return raw + } + if err := keepAuthority(state, delivery.Composed{Resources: []json.RawMessage{res("web.conf", "x")}}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(authorityPath(state)); !os.IsNotExist(err) { + t.Fatalf("a composition with no authority kept one: %v", err) + } + if err := keepAuthority(state, delivery.Composed{Resources: []json.RawMessage{res("ca-trust.certificate-authority", "CA-1")}}); err != nil { + t.Fatal(err) + } + if got := recordKeys(state)(); got.Authority != "CA-1" { + t.Errorf("the record's keys read the authority %q", got.Authority) + } +} diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index f00c38d..305315b 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -2152,6 +2152,9 @@ func recordKeys(state string) func() link.RecordKeys { if cert, err := os.ReadFile(identity.CertificatePath(state)); err == nil { k.Certificate = string(cert) } + if authority, err := os.ReadFile(authorityPath(state)); err == nil { + k.Authority = string(authority) + } return k } } diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index 8246895..960df22 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -404,6 +404,15 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, sess } var ag *agent.Agent kept := keptOnce(opts, signer) + // The mesh's own certificate the declaration kept from before carries, kept for the record before this machine + // composes its own (ADR 0555 §3): without it a module that asks for a certificate would be stood in for. + if byModule, _ := kept(); byModule != nil { + for module, resources := range byModule { + if err := keepAuthority(opts.state, delivery.Composed{Module: module, Resources: resources}); err != nil { + say("the mesh's certificate the kept declaration carries could not be kept for this machine's record: " + err.Error()) + } + } + } // The bus's user list, written alone from the newest composition of the bus module (ADR 0547 §8): on every // node, and a node whose compositions carry no list writes nothing. users := userListFollower(opts, session, composedBucket, trustedKeys(opts, signer), say, func() { @@ -462,6 +471,12 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, sess res = applyComposition(actx, opts, held, others, sched, say) } done() + // The mesh's own certificate it carries is kept for this machine's record (ADR 0555 §3). + if !res.Failed { + if err := keepAuthority(opts.state, c); err != nil { + say("the mesh's certificate in " + c.Module + "'s composition could not be kept for this machine's record: " + err.Error()) + } + } // An apply of the bus module may write an older list than the newest composition holds: looked at again. if module, _ := users.State(); module != "" && module == c.Module { users.Again() diff --git a/go.mod b/go.mod index 3e95fe5..b4f5a66 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.20261026181846-91413de33516 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026182306-944a35d200c9 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 2582f40..3628a34 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026181356-4d5f43022453 h1:TemVEm3x git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026181356-4d5f43022453/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026181846-91413de33516 h1:xbbss6kQFrAiyy8UEvqh2Ddi2KP+O9oTUSh7GkqmCYU= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026181846-91413de33516/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026182306-944a35d200c9 h1:ls5Jwxw4CZ9aPVhjQm9/qM88erjUU0ENRWajhD7lfb0= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026182306-944a35d200c9/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/agent/agent_test.go b/internal/agent/agent_test.go index 50d3341..c111a31 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -547,6 +547,7 @@ func TestABackupDoneIsFollowedByTheApplyAndANewModuleIsNotBackedUp(t *testing.T) // R6-A AC4 at the agent: a removed want removes that module only, and its have is written removed. func TestARemovedWantRemovesThatModuleOnly(t *testing.T) { + t.Run("its composition holds nothing back", testARemovedModulesCompositionHoldsNothingBack) b := newBed(t) stop := b.run() defer stop() @@ -790,7 +791,7 @@ func TestTheNodesOwnApplyHoldsOutAModuleThatMovedToItsOwnComposition(t *testing. // A module the assigner removed holds nothing back, its compositions still in the bucket or not: its resources are its // removal's to take (issue 1945). -func TestARemovedModulesCompositionHoldsNothingBack(t *testing.T) { +func testARemovedModulesCompositionHoldsNothingBack(t *testing.T) { b := newBed(t) fp := fpOf("b") rk, rraw, _ := composition(t, "gone", fp, false, file("gone", "x", "1")) diff --git a/internal/link/record.go b/internal/link/record.go index ea48667..1dc28a6 100644 --- a/internal/link/record.go +++ b/internal/link/record.go @@ -21,6 +21,8 @@ type RecordKeys struct { SealingKey string ServingKey string Certificate string + // Authority is the mesh's own certificate as this machine last applied it (public). + Authority string } // recordOf is the node's record from one facts report and its keys; nil for a report that carries no profile (one that @@ -43,7 +45,7 @@ func recordOf(node string, r Report, keys RecordKeys) *delivery.NodeRecord { return nil } rec := &delivery.NodeRecord{V: delivery.Version, Node: node, TunnelKey: keys.TunnelKey, SealingKey: keys.SealingKey, - ServingKey: keys.ServingKey, Certificate: keys.Certificate} + ServingKey: keys.ServingKey, Certificate: keys.Certificate, Authority: keys.Authority} for _, c := range p.Capabilities { if c.Present && c.Name != "" { rec.Capabilities = append(rec.Capabilities, c.Name)