diff --git a/cmd/mesh-compose/main_test.go b/cmd/mesh-compose/main_test.go index 83a5faf..0b7de35 100644 --- a/cmd/mesh-compose/main_test.go +++ b/cmd/mesh-compose/main_test.go @@ -280,7 +280,8 @@ func ownPackages(t *testing.T) map[string][]string { } // 1621/AC2 and AC4: the command imports, through every package of this repository it reaches, only the standard -// library, mesh-sdk and this repository's own packages, and none that reaches a bus, a store, a network service or +// library, mesh-sdk, the private network's overlay package (mesh-wireguard's own pure package: names, graph and +// generator, no bus) and this repository's own packages, and none that reaches a bus, a store, a network service or // another process. func TestTheCommandImportsNoBusStoreNetworkOrProcess(t *testing.T) { forbidden := []string{"net/http", "net/rpc", "net/smtp", "os/exec", "database/sql", "plugin", "syscall"} @@ -291,7 +292,7 @@ func TestTheCommandImportsNoBusStoreNetworkOrProcess(t *testing.T) { for path, imports := range own { for _, imp := range imports { std := !strings.Contains(strings.SplitN(imp, "/", 2)[0], ".") - if !std && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-sdk/go") && !strings.HasPrefix(imp, "github.com/novox/mesh-host/") { + if !std && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-sdk/go") && !strings.HasPrefix(imp, "github.com/novox/mesh-host/") { t.Errorf("%s imports %s, which is neither the standard library, mesh-sdk nor this repository", path, imp) } for _, f := range forbidden { diff --git a/cmd/mesh-host/authority_test.go b/cmd/mesh-host/authority_test.go index 0ff93a1..7ff84f8 100644 --- a/cmd/mesh-host/authority_test.go +++ b/cmd/mesh-host/authority_test.go @@ -13,9 +13,9 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-contracts/go/seats/meshvault" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/overlay" "github.com/novox/mesh-host/internal/identity" ) diff --git a/cmd/mesh-host/engineseat.go b/cmd/mesh-host/engineseat.go index 9d1f309..31c7731 100644 --- a/cmd/mesh-host/engineseat.go +++ b/cmd/mesh-host/engineseat.go @@ -14,8 +14,8 @@ import ( "time" nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" - occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" stdio "git.novox.be/novox/mesh-sdk/go" "github.com/novox/mesh-host/internal/accounts" "github.com/novox/mesh-host/internal/apply" diff --git a/cmd/mesh-host/libraries_test.go b/cmd/mesh-host/libraries_test.go index da6902a..ddb3d07 100644 --- a/cmd/mesh-host/libraries_test.go +++ b/cmd/mesh-host/libraries_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" - occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient" - oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/apply" "github.com/novox/mesh-host/internal/link" diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index 66af382..30d4c74 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/novox/mesh-host/internal/compositions" "os" "path/filepath" "sort" @@ -14,9 +15,9 @@ import ( "sync/atomic" "time" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/overlay" "github.com/novox/mesh-host/internal/agent" "github.com/novox/mesh-host/internal/apply" "github.com/novox/mesh-host/internal/compose/frombus" @@ -603,7 +604,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus unseal: sealOpener(opts.state), // And what each module's have says it runs sealed: after a restart the agent has applied nothing yet. // A have the bus did not answer for is said as such, never as one holding no seals (issue 1126, 2026-10-28). - held: func(module string) (delivery.Seals, []string, error) { + held: func(module string) (compositions.Seals, []string, error) { if module == agent.NodeModule { got, _, err := session.ReadNodeHaveErr(linked) return got.SealedAt, got.Sealed, err @@ -985,7 +986,7 @@ type composerHooks struct { unseal func(string) ([]byte, error) refused func(why string, held map[string]string) kept func(kept map[string][]string) - held func(module string) (delivery.Seals, []string, error) + held func(module string) (compositions.Seals, []string, error) // vaultAuthority and certify are selfcompose.Env's VaultAuthority and Certify. vaultAuthority func() string certify func(ctx context.Context) error diff --git a/cmd/mesh-host/nodeagent_integration_test.go b/cmd/mesh-host/nodeagent_integration_test.go index 7eb33ea..ba08a53 100644 --- a/cmd/mesh-host/nodeagent_integration_test.go +++ b/cmd/mesh-host/nodeagent_integration_test.go @@ -1019,3 +1019,25 @@ func TestNatsANodeThatCannotReadTheSeatListActsOnNothingNew(t *testing.T) { t.Errorf("web does not stay as it ran after a start with the seat gone: %q", b.file("web")) } } + +// The first read of the seat list ends the wait whether or not it changed what the reader held: Start returns at once, and +// the node waits on the reader's Ready before it says the read is over (ADR 0636 §1). +func TestNatsTheFirstReadOfTheSeatListEndsTheNodesWait(t *testing.T) { + clock := &fiveMinutes{} + was := seatWaitClock + seatWaitClock = clock + t.Cleanup(func() { seatWaitClock = was }) + + b := newItBedAs(t, nodegrant.For(itNode, "it2.mailer.domain")) + stop, _ := h8(t, b, false) + defer stop() + within(t, 30*time.Second, "the node did not end its wait at the first read of the seat list", func() bool { + st := seatListNow.Load() + if st == nil { + return false + } + <-st.reader.Ready() + waiting, _ := st.waiter.Waiting() + return st.reader.Status().Heard && !waiting + }) +} diff --git a/cmd/mesh-host/seatlist.go b/cmd/mesh-host/seatlist.go index ca6daec..6ad0ce2 100644 --- a/cmd/mesh-host/seatlist.go +++ b/cmd/mesh-host/seatlist.go @@ -138,12 +138,13 @@ func (w findingsOfTheNode) Delete(f views.Finding) error { // it holds again, saying so to the composer and to the waiter that writes the five-minute finding. func startSeatList(ctx context.Context, session *link.Session, node string, say func(string)) (*seatListState, error) { st := &seatListState{changed: make(chan struct{}, 1)} - var announce func() + // Start returns at once and reads the seat on a goroutine, which may announce a change before the waiter exists. + var announce atomic.Pointer[func()] reader, err := seatlist.Start(seatlist.Config{Bus: sessionBus{ctx, session}, Bootstrap: seatlist.Bootstrapped(), OnChange: func(seats []catalogue.Seat) { catalogue.UseSeats(seats) - if announce != nil { // the first read happens inside Start, before the waiter exists - announce() + if f := announce.Load(); f != nil { // the first read may end before the waiter exists + (*f)() } }}) if err != nil { @@ -152,15 +153,27 @@ func startSeatList(ctx context.Context, session *link.Session, node string, say st.reader = reader st.waiter = &seatwait.Waiter{Node: node, Observer: "node-engine", Reader: reader, Findings: findingsOfTheNode{session}, Clock: seatWaitClock, Say: say} - announce = func() { + told := func() { st.waiter.Changed() select { case st.changed <- struct{}{}: default: } } + announce.Store(&told) catalogue.UseSeats(reader.Seats()) - announce() + told() + // The first read ends whether the seat answered or not, and a read that held what the reader held already says no + // change: its end is told to the waiter and the composer, so a status that moved from reading to heard starts or ends + // the bound (ADR 0636 §1). Start's return held nobody's start. + go func() { + select { + case <-reader.Ready(): + catalogue.UseSeats(reader.Seats()) + told() + case <-ctx.Done(): + } + }() seatListNow.Store(st) return st, nil } diff --git a/cmd/mesh-host/trust.go b/cmd/mesh-host/trust.go index 11e0ad3..4ff1901 100644 --- a/cmd/mesh-host/trust.go +++ b/cmd/mesh-host/trust.go @@ -13,9 +13,9 @@ import ( "sync" "time" - "git.novox.be/novox/mesh-contracts/go/seats/meshvault" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/overlay" "github.com/novox/mesh-host/internal/identity" "github.com/novox/mesh-host/internal/link" diff --git a/cmd/mesh-host/whatruns_test.go b/cmd/mesh-host/whatruns_test.go index 05d809f..c756df7 100644 --- a/cmd/mesh-host/whatruns_test.go +++ b/cmd/mesh-host/whatruns_test.go @@ -10,9 +10,9 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/overlay" "github.com/novox/mesh-host/internal/agent" "github.com/novox/mesh-host/internal/liveness" diff --git a/go.mod b/go.mod index 17392cf..17ac313 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/novox/mesh-host go 1.26.0 require ( + git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 @@ -10,16 +11,14 @@ require ( golang.org/x/sys v0.48.0 ) -require git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect - require ( git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104174344-ae5266e77639 - git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104174344-ae5266e77639 - git.novox.be/novox/mesh-contracts/go/seats/meshvault v0.0.0-20261104174344-ae5266e77639 + git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-20261105000000-000000000000 + git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104174344-ae5266e77639 - git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104174344-ae5266e77639 + git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-20261105000000-000000000000 github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/klauspost/compress v1.20.1 // indirect diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 1367c9f..198a966 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -17,6 +17,7 @@ import ( "context" "encoding/json" "fmt" + "github.com/novox/mesh-host/internal/compositions" "slices" "sort" "strings" @@ -68,7 +69,7 @@ type Have struct { // Sealed is, on a node composing its own, each value the composition it names carries sealed to the node's key, by // slot (delivery.Have.SealedAt): kept in the have so the composer reuses them after a restart (ADR 0547 §3), each at // its own slot alone (issue 1126, C1). - Sealed delivery.Seals + Sealed compositions.Seals // PullWait is, on a failed have, the pull from a store its apply failed on and waits on the holder of (issue 1966 // ENG-9, delivery.Have.PullWait): read through the have, as node_haves shows it. PullWait *delivery.PullWait @@ -702,15 +703,15 @@ func (a *Agent) KeptNow(ctx context.Context, kept map[string][]string) { } } -func (a *Agent) sealedOf(ctx context.Context, module, fp, composed string) delivery.Seals { +func (a *Agent) sealedOf(ctx context.Context, module, fp, composed string) compositions.Seals { a.mu.Lock() if c, ok := a.applied[module]; ok && c.Err == nil && c.Dig == composed { a.mu.Unlock() - return delivery.SealsIn(c.C) + return compositions.SealsIn(c.C) } if c, ok := a.compositionFor(module, fp); ok && c.Err == nil && c.Dig == composed { a.mu.Unlock() - return delivery.SealsIn(c.C) + return compositions.SealsIn(c.C) } a.mu.Unlock() if h, ok := a.env.Haves.Read(ctx, module); ok && h.Composed == composed { @@ -1771,7 +1772,7 @@ func (a *Agent) waitsForYes(ctx context.Context, module string, w wantNow, comp // for a yes nobody needs (issue 774, 2026-10-31: four providers stayed waiting-yes once their consumers composed). if h, ok := a.env.Haves.Read(ctx, module); ok && h.Phase == string(delivery.PhaseWaitingYes) { // Its sealed values only when they are of what runs; else writeHave reads them (sealedOf). - var sealed delivery.Seals + var sealed compositions.Seals if h.Composed == r.Composed { sealed = h.Sealed } diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 92137b6..4431c3b 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "github.com/novox/mesh-host/internal/compositions" "maps" "slices" "strings" @@ -1583,7 +1584,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( // agent's start (the switch and every delivery of the node-engine restart it), so what runs is what its have said. idleFP, idleDig := fpOf("c"), "sha256:"+strings.Repeat("9", 64) b.haves.Write(context.Background(), Have{Module: "idle", FP: idleFP, Composed: idleDig, Phase: string(delivery.PhaseRunning), - Sealed: delivery.Seals{"idle.key#sealed": `{"box":"idle"}`}}) + Sealed: compositions.Seals{"idle.key#sealed": `{"box":"idle"}`}}) want("idle", idleFP, "") eventually(t, "idle's want was not taken", func() bool { b.ag.mu.Lock() @@ -1742,7 +1743,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( // what runs (fingerprint, digest, sealed values) rather than naming nothing (review of L2, round 3). recFP, recDig := fpOf("d"), "sha256:"+strings.Repeat("8", 64) b.haves.Write(context.Background(), Have{Module: "rec", FP: recFP, Composed: recDig, Phase: string(delivery.PhaseRunning), - Sealed: delivery.Seals{"rec.key#sealed": `{"box":"rec"}`}}) + Sealed: compositions.Seals{"rec.key#sealed": `{"box":"rec"}`}}) rk, rraw, rdig := unsigned("rec", fpB, container("rec@sha256:"+strings.Repeat("3", 64))) b.comps.put(dk2, draw2) b.comps.put(rk, rraw) @@ -1751,9 +1752,9 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( applied := len(b.appliedList()) for _, row := range []struct { module, fp, composed, waits string - sealed delivery.Seals + sealed compositions.Seals alone bool - }{{"db", fp, "", ddig2, nil, false}, {"rec", recFP, recDig, rdig, delivery.Seals{"rec.key#sealed": `{"box":"rec"}`}, true}} { + }{{"db", fp, "", ddig2, nil, false}, {"rec", recFP, recDig, rdig, compositions.Seals{"rec.key#sealed": `{"box":"rec"}`}, true}} { eventually(t, row.module+"'s move did not wait for a yes", func() bool { h := b.haves.last(row.module) return h.Phase == string(delivery.PhaseWaitingYes) && h.Waits == row.waits diff --git a/internal/compose/composed.go b/internal/compose/composed.go index 5d5df71..024f287 100644 --- a/internal/compose/composed.go +++ b/internal/compose/composed.go @@ -8,11 +8,11 @@ import ( "sort" "strings" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose/resolution" - "github.com/novox/mesh-host/internal/overlay" ) // Compositions is one node's compositions (novox/hq ADR 0539 clauses 1 and 2): its declaration composed over in, as diff --git a/internal/compose/composed_test.go b/internal/compose/composed_test.go index c66878b..09dacb5 100644 --- a/internal/compose/composed_test.go +++ b/internal/compose/composed_test.go @@ -6,10 +6,10 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose/resolution" - "github.com/novox/mesh-host/internal/overlay" ) func fpFor(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) } diff --git a/internal/compose/credentials_test.go b/internal/compose/credentials_test.go index 0ab188b..50ddfc6 100644 --- a/internal/compose/credentials_test.go +++ b/internal/compose/credentials_test.go @@ -6,8 +6,8 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-host/internal/overlay" ) // secretValue is a value the Input carries that must never appear in anything the composition says. diff --git a/internal/compose/fixture_test.go b/internal/compose/fixture_test.go index 1766852..69a0ec7 100644 --- a/internal/compose/fixture_test.go +++ b/internal/compose/fixture_test.go @@ -8,8 +8,8 @@ import ( "reflect" "testing" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-host/internal/overlay" ) // aPublicKey is a key a machine could have reported, made as mesh-controller's mesh_for_test.go aPublicKey makes it. diff --git a/internal/compose/frombus/answer.go b/internal/compose/frombus/answer.go index 79be25e..14b1b93 100644 --- a/internal/compose/frombus/answer.go +++ b/internal/compose/frombus/answer.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "github.com/novox/mesh-host/internal/compositions" "maps" "regexp" "slices" @@ -18,7 +19,7 @@ import ( // Answer is a node's answer to its proposal's placement candidates (novox/hq ADR 0567 §3, ADR 0559 §4): s.Node composed in shadow with // s.Placements laid over the trunk's placement (compose.CompositionsShadow, its own sealed values and boxes through r as -// for its own composition), compared with what it runs (delivery.CompareComposed). It applies nothing and writes +// for its own composition), compared with what it runs (compositions.CompareComposed). It applies nothing and writes // nothing; the proposal and the revision are the candidate's, said back in the answer. // // The node's own composition is compared only where the node runs one. fps gives the build each module is composed at: what the node runs, and for a module the candidate places that the @@ -115,7 +116,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st } running = judged } - cmp := delivery.CompareComposedWith(composed, running, s.SameValue) + cmp := compositions.CompareComposedWith(composed, running, s.SameValue) // The answer rides in the node's have key: its shown bodies are bounded, the rest named only. budget := MaxShown for i := range cmp.Differs { diff --git a/internal/compose/frombus/answer_rows_test.go b/internal/compose/frombus/answer_rows_test.go index 05a7fe9..113c4f0 100644 --- a/internal/compose/frombus/answer_rows_test.go +++ b/internal/compose/frombus/answer_rows_test.go @@ -10,11 +10,11 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/resolution" - "github.com/novox/mesh-host/internal/overlay" ) // Issue 1126 (the seed's shadow, round 2): a node's answer names every module it cannot compose, not the first only; diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index 9d5c171..5288ca7 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -41,12 +41,12 @@ import ( "sort" "strings" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/resolution" - "github.com/novox/mesh-host/internal/overlay" "github.com/novox/mesh-host/internal/takein" ) @@ -152,7 +152,7 @@ type Scope struct { // behind one of them, keeps it as today, which is said under CandidateAnswer.Held and is no refusal (issue 1126). HeldToday map[string]string // SameValue, in an answer, says whether two sealed values open to the same value (the node opens them with its own - // key), so a differing resource shows a value sealed again as such (delivery.CompareComposedWith). Nil shows them + // key), so a differing resource shows a value sealed again as such (compositions.CompareComposedWith). Nil shows them // as differing. SameValue func(a, b string) (same, known bool) // Kept is told, in words, each module composed as what runs shows because its manifest does not yet say it (the tool diff --git a/internal/compose/frombus/frombus_test.go b/internal/compose/frombus/frombus_test.go index 3f4e889..0ead30e 100644 --- a/internal/compose/frombus/frombus_test.go +++ b/internal/compose/frombus/frombus_test.go @@ -11,6 +11,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/novox/mesh-host/internal/compositions" "os" "reflect" "slices" @@ -18,10 +19,10 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose" - "github.com/novox/mesh-host/internal/overlay" ) // bus is a Reader over values held in memory, counting whose config values were read. @@ -293,7 +294,7 @@ func TestInput(t *testing.T) { if err != nil { t.Fatal(err) } - if c := delivery.CompareComposed(asIs, running); len(c.Differs)+len(c.Added)+len(c.Removed) != 0 || len(c.Same) != len(running) { + if c := compositions.CompareComposed(asIs, running); len(c.Differs)+len(c.Added)+len(c.Removed) != 0 || len(c.Same) != len(running) { t.Errorf("the candidate equal to the placement: %+v", c) } moved := mk().placements["anchor"] @@ -305,10 +306,10 @@ func TestInput(t *testing.T) { if err != nil { t.Fatal(err) } - if c := delivery.CompareComposed(changed, running); len(c.Differs) != 1 || c.Differs[0].Module != overlay.Name || c.Differs[0].Resource != overlay.Name+".overlay-config" { + if c := compositions.CompareComposed(changed, running); len(c.Differs) != 1 || c.Differs[0].Module != overlay.Name || c.Differs[0].Resource != overlay.Name+".overlay-config" { t.Errorf("the hub's endpoint changed: %+v, want %s alone, naming its config", c, overlay.Name) } - if c := delivery.CompareComposed(changed, running[:0]); len(c.Added) != len(changed) { + if c := compositions.CompareComposed(changed, running[:0]); len(c.Added) != len(changed) { t.Errorf("nothing runs: %+v", c) } diff --git a/internal/compose/network.go b/internal/compose/network.go index 4e11abf..1ba2dfd 100644 --- a/internal/compose/network.go +++ b/internal/compose/network.go @@ -8,9 +8,9 @@ import ( "strconv" "strings" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" "github.com/novox/mesh-host/internal/compose/resolution" - "github.com/novox/mesh-host/internal/overlay" ) // The private network: who is on it, where, and what they are called. Ported from mesh-controller diff --git a/internal/compose/network_test.go b/internal/compose/network_test.go index 2f924f9..3b1ca59 100644 --- a/internal/compose/network_test.go +++ b/internal/compose/network_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-host/internal/overlay" ) // onTheTunnel makes the hub of an Input what mesh-controller's network_test.go makes of anchor in diff --git a/internal/compose/node_test.go b/internal/compose/node_test.go index 1d2c67b..6735f95 100644 --- a/internal/compose/node_test.go +++ b/internal/compose/node_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-host/internal/overlay" ) func resourceIDs(d Declared) []string { diff --git a/internal/compose/one_machine_alone_test.go b/internal/compose/one_machine_alone_test.go index 3c49abb..f05accb 100644 --- a/internal/compose/one_machine_alone_test.go +++ b/internal/compose/one_machine_alone_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-host/internal/overlay" ) // fourOnTheNetwork is aMesh and two more machines on the private network at the site `there`: `ace`, dialled at diff --git a/internal/compose/rendering.go b/internal/compose/rendering.go index 7d06652..d0b2afe 100644 --- a/internal/compose/rendering.go +++ b/internal/compose/rendering.go @@ -7,9 +7,9 @@ import ( "sort" "strings" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" "github.com/novox/mesh-host/internal/compose/resolution" - "github.com/novox/mesh-host/internal/overlay" ) // The contracts of the node-engine's statement that gate what a machine is sent, as the controller's link package names them diff --git a/internal/compose/rendering_test.go b/internal/compose/rendering_test.go index 8c50b5b..78d7a79 100644 --- a/internal/compose/rendering_test.go +++ b/internal/compose/rendering_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-host/internal/overlay" ) // aWebModule publishes one port in the short form, which the mesh is free to move (MachineSide). diff --git a/internal/compose/resolve_test.go b/internal/compose/resolve_test.go index 0696afb..d361b8c 100644 --- a/internal/compose/resolve_test.go +++ b/internal/compose/resolve_test.go @@ -5,9 +5,9 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" "github.com/novox/mesh-host/internal/compose/resolution" - "github.com/novox/mesh-host/internal/overlay" ) // storeManifests is mesh-controller's bindings_test.go storeManifests (cmd/mesh-controller at 6c2a8d62): a store holding the diff --git a/internal/compose/rest_test.go b/internal/compose/rest_test.go index f771c10..d0b0d2b 100644 --- a/internal/compose/rest_test.go +++ b/internal/compose/rest_test.go @@ -4,9 +4,9 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" "github.com/novox/mesh-host/internal/compose/resolution" - "github.com/novox/mesh-host/internal/overlay" ) // objects and files are the provider and consumer of mesh-controller's facts_test.go aMeshWithSecrets (cmd/mesh-controller at diff --git a/internal/compositions/answer_paths.go b/internal/compositions/answer_paths.go new file mode 100644 index 0000000..a3217a4 --- /dev/null +++ b/internal/compositions/answer_paths.go @@ -0,0 +1,137 @@ +package compositions + +import ( + "bytes" + "encoding/json" + "git.novox.be/novox/mesh-sdk/go/delivery" + "sort" + "strconv" + "strings" +) + +// DifferingPaths is every JSON path at which two bodies of one resource differ, sorted (issue 1126, T1: the shadow +// shows its work): object keys and list places joined by dots (`content.url`, `links.0`), a string that holds a JSON +// object or list read as that JSON and walked on (a runtime's config carried as text), a key or place one side alone +// holds named as it is. A key a secret could be under (secretWords) is named and not walked into. A body that is not +// JSON differs at "" (the whole), and a resource one side alone holds has none. At most MaxPaths: the last then says how many more there are. A path names keys +// alone, never a value, so it holds no secret. +func DifferingPaths(composed, running json.RawMessage) []string { + if composed == nil || running == nil { + return nil // one side alone holds it: its why says so + } + var a, b any + okA := unmarshalNumbers(composed, &a) == nil + okB := unmarshalNumbers(running, &b) == nil + if !okA || !okB { + if bytes.Equal(composed, running) { + return nil + } + return []string{""} + } + var out []string + walkDiffering(a, b, "", &out) + sort.Strings(out) + if len(out) > delivery.MaxPaths { + more := len(out) - (delivery.MaxPaths - 1) + out = append(out[:delivery.MaxPaths-1], "… "+strconv.Itoa(more)+" more") + } + return out +} + +func unmarshalNumbers(raw []byte, into *any) error { + d := json.NewDecoder(bytes.NewReader(raw)) + d.UseNumber() + return d.Decode(into) +} + +// asJSON is a string holding a JSON object or list read as that value; v itself otherwise. +func asJSON(v any) any { + s, ok := v.(string) + if !ok { + return v + } + t := strings.TrimSpace(s) + if t == "" || (t[0] != '{' && t[0] != '[') { + return v + } + var parsed any + if unmarshalNumbers([]byte(t), &parsed) != nil { + return v + } + return parsed +} + +func join(at, key string) string { + if at == "" { + return key + } + return at + "." + key +} + +func walkDiffering(a, b any, at string, out *[]string) { + a, b = asJSON(a), asJSON(b) + ma, isMA := a.(map[string]any) + mb, isMB := b.(map[string]any) + if isMA && isMB { + keys := map[string]bool{} + for k := range ma { + keys[k] = true + } + for k := range mb { + keys[k] = true + } + for k := range keys { + x, inA := ma[k] + y, inB := mb[k] + switch { + case !inA || !inB: + *out = append(*out, join(at, k)) + case secretKey(k): + // A file's secrets (SecretsField) are named entry by entry, each entry never entered; any other + // object under a secret key, a vault box included, is one path. + sx, isSX := x.(map[string]any) + sy, isSY := y.(map[string]any) + if k != SecretsField || !isSX || !isSY { + if !sameAny(x, y) { + *out = append(*out, join(at, k)) + } + continue + } + for name := range sx { + if v, in := sy[name]; !in || !sameAny(sx[name], v) { + *out = append(*out, join(join(at, k), name)) + } + } + for name := range sy { + if _, in := sx[name]; !in { + *out = append(*out, join(join(at, k), name)) + } + } + default: + walkDiffering(x, y, join(at, k), out) + } + } + return + } + la, isLA := a.([]any) + lb, isLB := b.([]any) + if isLA && isLB { + for i := range max(len(la), len(lb)) { + if i >= len(la) || i >= len(lb) { + *out = append(*out, join(at, strconv.Itoa(i))) + continue + } + walkDiffering(la[i], lb[i], join(at, strconv.Itoa(i)), out) + } + return + } + if !sameAny(a, b) { + *out = append(*out, at) + } +} + +func sameAny(a, b any) bool { + x, errA := json.Marshal(a) + y, errB := json.Marshal(b) + return errA == nil && errB == nil && bytes.Equal(x, y) +} diff --git a/internal/compositions/compare_test.go b/internal/compositions/compare_test.go new file mode 100644 index 0000000..fa5bc42 --- /dev/null +++ b/internal/compositions/compare_test.go @@ -0,0 +1,169 @@ +package compositions + +import ( + "bytes" + "encoding/json" + "fmt" + "slices" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// What the node-engine finds when it compares the compositions it made with the ones it runs (issue 1126): the bus's user +// list left out, a resource that differs named with both digests, a value sealed again judged by what it opens to, every +// differing body shown with its secrets masked, and the paths found on the whole bodies. Moved from mesh-sdk's delivery +// package with the code (ADR 0634 §1: only the node-engine compares compositions). +func TestComparingCompositions(t *testing.T) { + // CompareComposed: the bus's user list is left out on both sides; anything else that differs is named. + file := func(id, content string) json.RawMessage { + return json.RawMessage(`{"id":"` + id + `","type":"file","module":"nats","path":"/etc/` + id + `","mode":"0600","content":"` + content + `"}`) + } + fp := "src2:" + strings.Repeat("a", 64) + comp := func(rs ...json.RawMessage) delivery.Composed { + return delivery.Composed{Node: "novox", Module: "nats", FP: fp, Resources: rs} + } + for name, tc := range map[string]struct { + composed, running delivery.Composed + resource, why string + }{ + "user list alone": {comp(file("conf", "a"), file("nats.bus-users", "stand-in")), comp(file("conf", "a"), file("nats.bus-users", "real")), "", ""}, + "a resource": {comp(file("conf", "a")), comp(file("conf", "b")), "conf", "the resource differs"}, + "one side": {comp(file("conf", "a"), file("more", "x")), comp(file("conf", "a")), "more", "composed, and not in what runs"}, + "order": {comp(file("conf", "a"), file("more", "x")), comp(file("more", "x"), file("conf", "a")), "", "the same resources in another order"}, + } { + c := CompareComposed(map[string]delivery.Composed{"novox.nats." + fp: tc.composed}, []delivery.Composed{tc.running}) + if tc.why == "" { + if len(c.Differs) != 0 || len(c.Same) != 1 { + t.Errorf("%s: %+v", name, c) + } + } else if len(c.Differs) != 1 || c.Differs[0].Resource != tc.resource || c.Differs[0].Why != tc.why || + c.Differs[0].Composed == "" || c.Differs[0].Running == "" || c.Differs[0].Composed == c.Differs[0].Running { + t.Errorf("%s: differs %+v, want %s: %s with both digests", name, c.Differs, tc.resource, tc.why) + } + } + // Issue 1126, the shadow shows its work: every differing resource with both bodies, each value a secret could be + // in masked (never a secret in an answer), saying whether the two sides hold the same value. + sealedFile := func(id, sealed, mode string) json.RawMessage { + return json.RawMessage(`{"id":"` + id + `","type":"file","module":"nats","path":"/etc/` + id + `","mode":"` + mode + `","sealed":"` + sealed + `"}`) + } + // Issue 1126, C1: a sealed value is judged by the value it opens to at its own slot: b sealed again (box-2 and + // box-3 open to one value) is no difference; d holds at its slot a box of another value, though that value is + // the one b holds, and differs at its path `sealed`. + sameBox := func(a, b string) (same, known bool) { + return (a == "box-2" && b == "box-3") || a == b, true + } + shown := CompareComposedWith(map[string]delivery.Composed{"k": comp(sealedFile("a", "box-1", "0600"), sealedFile("b", "box-2", "0600"), + file("c", "x"), sealedFile("d", "box-2", "0600"))}, + []delivery.Composed{comp(sealedFile("a", "box-1", "0644"), sealedFile("b", "box-3", "0600"), file("c", "y"), sealedFile("d", "box-4", "0600"))}, + sameBox) + if len(shown.Differs) != 1 || len(shown.Differs[0].Bodies) != 3 { + t.Fatalf("every differing resource is not shown, or b sealed again is shown: %+v", shown.Differs) + } + if c := CompareComposedWith(map[string]delivery.Composed{"k": comp(sealedFile("b", "box-2", "0600"))}, + []delivery.Composed{comp(sealedFile("b", "box-3", "0600"))}, sameBox); len(c.Same) != 1 { + t.Errorf("a value sealed again at its own slot is not the same: %+v", c) + } + for _, row := range []struct { + id, has, hasNot string + paths []string + }{ + {"a", `"mode":"0600"`, "box-1", []string{"mode"}}, + {"c", `"content":"x"`, "", []string{"content"}}, + {"d", "", "box-2", []string{"sealed"}}, + } { + var body delivery.ResourceBodies + for _, b := range shown.Differs[0].Bodies { + if b.ID == row.id { + body = b + } + } + both := body.Composed + body.Running + if !strings.Contains(both, row.has) || (row.hasNot != "" && strings.Contains(both, row.hasNot)) || + !slices.Equal(body.Paths, row.paths) { + t.Errorf("%s shown as %+v, want paths %v", row.id, body, row.paths) + } + } + // Issue 1126, C1, the seed-7 probe: a file naming secrets carries each sealed value in its `secrets`, and each entry + // is a slot of its own. The shape is intoFile's output, captured from catalogue for a file naming two secrets + // (2026-10-28), with the module a composition names each resource of; only the boxes are replaced. + withSecrets := func(db, key string) json.RawMessage { + return json.RawMessage(`{"content":"[database]\npassword = ${secret:postgres-database}\n[security]\nsecret_key = ${secret:secret-key}\n",` + + `"id":"grafana.ini","mode":"0600","module":"nats","path":"/etc/grafana/grafana.ini","secrets":{"postgres-database":"` + db + + `","secret-key":"` + key + `"},"type":"file"}`) + } + if c := CompareComposedWith(map[string]delivery.Composed{"k": comp(withSecrets("box-2", "box-1"))}, + []delivery.Composed{comp(withSecrets("box-3", "box-1"))}, sameBox); len(c.Same) != 1 { + t.Errorf("a secret sealed again at its own entry is not the same: %+v", c) + } + if kept := KeepSeals(comp(withSecrets("box-2", "box-1")), SealsIn(comp(withSecrets("box-3", "box-1"))), sameBox); !bytes.Equal(kept.Resources[0], withSecrets("box-3", "box-1")) { + t.Errorf("the box that runs is not kept byte for byte: %s", kept.Resources[0]) + } + if s := SealsIn(comp(withSecrets("box-2", "box-1"))); s[SealSlot("grafana.ini", SecretSlotField("postgres-database"))] != "box-2" || len(s) != 2 { + t.Errorf("the secrets' slots are %v", s) + } + // Another value at one entry differs there alone, each entry masked by what it opens to. + // box-2 and box-4 open to one value, so the box that runs is kept there; box-5 and box-6 are two values. + mixed := CompareComposedWith(map[string]delivery.Composed{"k": comp(withSecrets("box-2", "box-5"))}, + []delivery.Composed{comp(withSecrets("box-4", "box-6"))}, func(a, b string) (bool, bool) { + return (a == "box-2" && b == "box-4") || a == b, true + }) + if len(mixed.Differs) != 1 || len(mixed.Differs[0].Bodies) != 1 { + t.Fatalf("a secret of another value is not shown: %+v", mixed) + } + if body := mixed.Differs[0].Bodies[0]; !slices.Equal(body.Paths, []string{"secrets.secret-key"}) || + !strings.Contains(body.Composed, `"secret-key":""`) || strings.Contains(body.Composed+body.Running, "box-") || + !strings.Contains(body.Running, `"postgres-database":""`) { + t.Errorf("a file's secrets shown as %+v", body) + } + if c, _ := MaskedPair(withSecrets("box-2", "box-5"), withSecrets("box-4", "box-6"), func(a, b string) (bool, bool) { + return a == "box-2", true + }); !strings.Contains(c, `"postgres-database":""`) || !strings.Contains(c, `"secret-key":""`) { + t.Errorf("each secret is not masked by what it opens to: %s", c) + } + // Only a file's secrets are split by entry: a vault box (an object under `vault-sealed`) is one masked word and one + // path, judged whole by what it opens to (rev-seed-same-3, round 1). + box := func(ct string) json.RawMessage { + return json.RawMessage(`{"id":"core-box","type":"file","module":"nats","path":"/etc/core","mode":"0600","vault-sealed":{"v":1,"eph":"e-` + + ct + `","ct":"` + ct + `"}}`) + } + vault := CompareComposedWith(map[string]delivery.Composed{"k": comp(box("c1"))}, []delivery.Composed{comp(box("c2"))}, + func(a, b string) (bool, bool) { + return false, strings.Contains(a, `"ct":"c1"`) && strings.Contains(b, `"ct":"c2"`) + }) + if len(vault.Differs) != 1 || len(vault.Differs[0].Bodies) != 1 { + t.Fatalf("a box of another value is not shown: %+v", vault) + } + if body := vault.Differs[0].Bodies[0]; !slices.Equal(body.Paths, []string{"vault-sealed"}) || + !strings.Contains(body.Composed, `"vault-sealed":""`) || strings.Contains(body.Composed+body.Running, "c1") { + t.Errorf("a vault box shown as %+v", body) + } + // Issue 1126, T1: the paths are found on the whole bodies, before they are cut; JSON in a string is walked into, a + // key a secret could be under is named and never shown. + long := strings.Repeat("x", delivery.MaxBody) + conf := func(link, password string) json.RawMessage { + inner, _ := json.Marshal(map[string]any{"pad": long, "links": []string{"a", link}, "password": password}) + outer, _ := json.Marshal(map[string]any{"id": "conf", "type": "file", "module": "nats", "path": "/etc/conf", + "mode": "0600", "content": string(inner)}) + return outer + } + far := CompareComposedWith(map[string]delivery.Composed{"k": comp(conf("b", "hunter2"))}, []delivery.Composed{comp(conf("c", "hunter3"))}, nil) + if len(far.Differs) != 1 || len(far.Differs[0].Bodies) != 1 || + !slices.Equal(far.Differs[0].Bodies[0].Paths, []string{"content.links.1", "content.password"}) || + !strings.Contains(far.Differs[0].Bodies[0].Composed, "cut at") || strings.Contains(fmt.Sprint(far), "hunter") { + t.Errorf("a difference past the cut: %+v", far.Differs) + } + if c, r := MaskedPair(json.RawMessage(`{"env":{"DB_PASSWORD":"hunter2","PORT":"80"},"list":[{"token":"t1"}]}`), + json.RawMessage(`{"env":{"DB_PASSWORD":"hunter3","PORT":"81"},"list":[{"token":"t1"}]}`), nil); strings.Contains(c+r, "hunter") || + strings.Contains(c+r, `"t1"`) || !strings.Contains(r, `"PORT":"81"`) || !strings.Contains(c, "") || + !strings.Contains(c, "") { + t.Errorf("masked as %s and %s", c, r) + } + if c := CompareComposed(nil, []delivery.Composed{{Node: "novox", Resources: []json.RawMessage{}}}); len(c.Removed) != 1 || c.Removed[0] != "node" { + t.Errorf("the node's own composition run and not composed is removed: %+v", c) + } + if c := CompareComposed(map[string]delivery.Composed{"k": comp(file("conf", "a"))}, nil); len(c.Added) != 1 || c.Added[0].Module != "nats" || c.Added[0].Composed == "" { + t.Errorf("a composition not run is added: %+v", c) + } +} diff --git a/internal/compositions/composed_compare.go b/internal/compositions/composed_compare.go new file mode 100644 index 0000000..fc244e0 --- /dev/null +++ b/internal/compositions/composed_compare.go @@ -0,0 +1,412 @@ +package compositions + +import ( + "bytes" + "encoding/json" + "errors" + "git.novox.be/novox/mesh-sdk/go/delivery" + "io" + "maps" + "slices" + "strconv" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// Comparison is what CompareComposed finds, module by module: the same, differing, added by what was composed (not run +// now) and removed by it (run now, not composed). Each list is sorted by module. +type Comparison struct { + Same []string + Differs []delivery.ModuleDiffers + Added []delivery.ModuleAdded + Removed []string +} + +// CompareComposed compares the compositions a node composed (by key, as compose.Compositions answers them) with the ones +// it runs, as its agent would (ADR 0559 §4: a node's answer to a placement candidate). A module is the same when its +// composition's digest is the one it runs (ComposedDigestOf, the signature left out), so the agent would apply nothing +// of it; it differs when the digests differ (both named); it is added when composed and not run, removed when run and +// not composed. The node's own composition is named HaveNodeWord. The bus's user list (catalogue.BusUsersID) is left out +// on both sides: the bus node follows it alone and never applies it from a composition it made (ADR 0547 §2, §8). A +// difference names the first resource, by its id, whose content differs or that one side alone holds; when every +// resource is equal it says what else differs (their order, the files filled, the data, the node's own parts, a file +// carried by digest). +func CompareComposed(composed map[string]delivery.Composed, running []delivery.Composed) Comparison { + return CompareComposedWith(composed, running, nil) +} + +// CompareComposedWith is CompareComposed showing, for each module that differs, every differing resource with both +// bodies masked (MaskedPair); sameValue, when given, says whether two sealed values open to the same value (the node +// opens them with its own key), so a value sealed again reads as such. +func CompareComposedWith(composed map[string]delivery.Composed, running []delivery.Composed, sameValue func(a, b string) (same, known bool)) Comparison { + name := func(c delivery.Composed) string { + if c.Module == "" { + return delivery.HaveNodeWord + } + return c.Module + } + ours := map[string]delivery.Composed{} + for _, c := range composed { + ours[name(c)] = c + } + theirs := map[string]delivery.Composed{} + for _, c := range running { + theirs[name(c)] = c + } + modules := map[string]bool{} + for m := range ours { + modules[m] = true + } + for m := range theirs { + modules[m] = true + } + var out Comparison + for _, m := range slices.Sorted(maps.Keys(modules)) { + a, composedHere := ours[m] + b, runs := theirs[m] + switch { + case !runs: + dig, _ := delivery.ComposedDigestOf(withoutBusUsers(a)) + out.Added = append(out.Added, delivery.ModuleAdded{Module: m, Composed: dig}) + case !composedHere: + out.Removed = append(out.Removed, m) + default: + if d, ok := compareOne(m, a, b, sameValue); ok { + out.Same = append(out.Same, m) + } else { + out.Differs = append(out.Differs, d) + } + } + } + return out +} + +// compareOne is one module's comparison: true when the digests agree, else what differs first. +func compareOne(module string, composed, running delivery.Composed, sameValue func(a, b string) (bool, bool)) (delivery.ModuleDiffers, bool) { + composed, running = withoutBusUsers(composed), withoutBusUsers(running) + // A sealed value is judged by the value it opens to, slot by slot (issue 1126, C1): a value sealed again at the same + // field of the same resource is the box that runs, never a difference; and a box is never matched across resources. + composed = KeepSeals(composed, SealsIn(running), sameValue) + dc, errC := delivery.ComposedDigestOf(composed) + dr, errR := delivery.ComposedDigestOf(running) + if errC != nil { + return delivery.ModuleDiffers{Module: module, Running: dr, Why: "the composition has no digest: " + errC.Error()}, false + } + if errR == nil && dc == dr { + return delivery.ModuleDiffers{}, true + } + differs := func(resource, why string) (delivery.ModuleDiffers, bool) { + return delivery.ModuleDiffers{Module: module, Composed: dc, Running: dr, Resource: resource, Why: why}, false + } + // Words for the difference: resource by resource, each side with its large files by digest, so a file carried whole + // and the same file carried by digest read as one. + a, _, errA := composed.ByDigest() + b, _, errB := running.ByDigest() + if errA != nil || errB != nil { + return differs("", "the compositions differ and cannot be read resource by resource") + } + ra, rb := resourcesByID(a.Resources), resourcesByID(b.Resources) + ids := map[string]bool{} + for id := range ra { + ids[id] = true + } + for id := range rb { + ids[id] = true + } + var first delivery.ModuleDiffers + found := false + var bodies []delivery.ResourceBodies + for _, id := range slices.Sorted(maps.Keys(ids)) { + x, inA := ra[id] + y, inB := rb[id] + why := "" + switch { + case !inB: + why = "composed, and not in what runs" + case !inA: + why = "in what runs, and not composed" + case !bytes.Equal(x, y): + why = "the resource differs" + default: + continue + } + if !found { + first, _ = differs(id, why) + found = true + } + if len(bodies) < delivery.MaxBodies { + // The paths are found on the whole bodies, before either is cut (issue 1126, T1: three resources over + // MaxBody could not be told apart). + c, r := MaskedPair(x, y, sameValue) + bodies = append(bodies, delivery.ResourceBodies{ID: id, Why: why, Paths: DifferingPaths(x, y), + Composed: cut(c), Running: cut(r)}) + } + } + if found { + first.Bodies = bodies + return first, false + } + switch { + case !jsonEqual(a.Filled, b.Filled): + return differs("", "the files the provisioner fills differ") + case a.Data != b.Data: + return differs("", "whether it declares data differs") + case !jsonEqual(a.Own, b.Own): + return differs("", "the node's own parts differ") + case a.FP != b.FP: + return differs("", "the fingerprint differs") + } + if dA, err := delivery.ComposedDigestOf(a); err == nil { + if dB, err := delivery.ComposedDigestOf(b); err == nil && dA == dB { + return differs("", "the same content, a large file carried whole on one side and by digest on the other") + } + } + return differs("", "the same resources in another order") +} + +// withoutBusUsers is c without the bus's user list resource (its id `bus-users`, or `.bus-users`). +func withoutBusUsers(c delivery.Composed) delivery.Composed { + out := c + out.Resources = nil + for _, raw := range c.Resources { + var head struct { + ID string `json:"id"` + } + if json.Unmarshal(raw, &head) == nil && (head.ID == catalogue.BusUsersID() || strings.HasSuffix(head.ID, "."+catalogue.BusUsersID())) { + continue + } + out.Resources = append(out.Resources, raw) + } + return out +} + +// resourcesByID is each resource canonical, by its id (a resource without one by its place). +func resourcesByID(resources []json.RawMessage) map[string][]byte { + out := make(map[string][]byte, len(resources)) + for i, raw := range resources { + var head struct { + ID string `json:"id"` + } + _ = json.Unmarshal(raw, &head) + id := head.ID + if id == "" { + id = "#" + strconv.Itoa(i) + } + canon, err := canonicalJSON(raw) + if err != nil { + canon = raw + } + out[id] = canon + } + return out +} + +// cut is s cut at MaxBody bytes, saying so. +func cut(s string) string { + if len(s) <= delivery.MaxBody { + return s + } + return s[:delivery.MaxBody] + "… (cut at " + strconv.Itoa(delivery.MaxBody) + " bytes)" +} + +// secretWords are the words of a key whose value may hold a secret or a sealed one: masked in a shown body. +var secretWords = []string{"sealed", "secret", "password", "passwd", "token", "private", "key", "credential", "jwt", "salt", "hash"} + +func secretKey(k string) bool { + k = strings.ToLower(k) + for _, w := range secretWords { + if strings.Contains(k, w) { + return true + } + } + return false +} + +// MaskedPair is two bodies of one resource (either may be nil) as JSON text with every value under a key a secret could +// be in (secretWords, at any depth) masked: "", "" or "" when sameValue says so, "" otherwise, "" on one side +// alone. A body that is not JSON is shown as its length only. +func MaskedPair(composed, running json.RawMessage, sameValue func(a, b string) (same, known bool)) (string, string) { + var a, b any + okA := composed != nil && json.Unmarshal(composed, &a) == nil + okB := running != nil && json.Unmarshal(running, &b) == nil + if okA && okB { + a, b = maskBoth(a, b, sameValue) + } else if okA { + a, _ = maskBoth(a, nil, nil) + } else if okB { + _, b = maskBoth(nil, b, nil) + } + show := func(v any, raw json.RawMessage, ok bool) string { + if raw == nil { + return "" + } + if !ok { + return "" + } + var out bytes.Buffer + enc := json.NewEncoder(&out) + enc.SetEscapeHTML(false) + _ = enc.Encode(v) + return strings.TrimSuffix(out.String(), "\n") + } + return show(a, composed, okA), show(b, running, okB) +} + +func maskBoth(a, b any, sameValue func(a, b string) (bool, bool)) (any, any) { + ma, isA := a.(map[string]any) + mb, isB := b.(map[string]any) + if isA || isB { + outA, outB := map[string]any{}, map[string]any{} + keys := map[string]bool{} + for k := range ma { + keys[k] = true + } + for k := range mb { + keys[k] = true + } + for k := range keys { + va, inA := ma[k] + vb, inB := mb[k] + if secretKey(k) { + x, y := maskAll(k, va, vb, inA && inB, sameValue) + if inA { + outA[k] = x + } + if inB { + outB[k] = y + } + continue + } + x, y := maskBoth(va, vb, sameValue) + if inA { + outA[k] = x + } + if inB { + outB[k] = y + } + } + var ra, rb any + if isA { + ra = outA + } else { + ra = a + } + if isB { + rb = outB + } else { + rb = b + } + return ra, rb + } + la, isLA := a.([]any) + lb, isLB := b.([]any) + if isLA || isLB { + outA := make([]any, len(la)) + outB := make([]any, len(lb)) + for i := range max(len(la), len(lb)) { + var x, y any + if i < len(la) { + x = la[i] + } + if i < len(lb) { + y = lb[i] + } + x, y = maskBoth(x, y, sameValue) + if i < len(la) { + outA[i] = x + } + if i < len(lb) { + outB[i] = y + } + } + var ra, rb any = a, b + if isLA { + ra = outA + } + if isLB { + rb = outB + } + return ra, rb + } + return a, b +} + +// maskAll is a value under a key a secret could be under, masked as one word (maskWord); a file's secrets +// (SecretsField) alone entry by entry, each its own sealed value: "another seal of the same value" or "another value" +// per secret, its name shown (issue 1126, C1). Any other object, a vault box included, is one word, judged whole. +func maskAll(key string, a, b any, both bool, sameValue func(a, b string) (bool, bool)) (any, any) { + ma, isA := a.(map[string]any) + mb, isB := b.(map[string]any) + if key != SecretsField || (!isA && !isB) { + w := maskWord(a, b, both, sameValue) + return w, w + } + outA, outB := map[string]any{}, map[string]any{} + for k, va := range ma { + vb, inB := mb[k] + outA[k] = maskWord(va, vb, isB && inB, sameValue) + } + for k, vb := range mb { + va, inA := ma[k] + outB[k] = maskWord(va, vb, isA && inA, sameValue) + } + // A map on one side and another value on the other: the other is one word. + var ra, rb any = outA, outB + if !isA { + ra = maskWord(a, b, both, sameValue) + } + if !isB { + rb = maskWord(a, b, both, sameValue) + } + return ra, rb +} + +func maskWord(a, b any, both bool, sameValue func(a, b string) (bool, bool)) string { + if !both { + return "" + } + x, _ := json.Marshal(a) + y, _ := json.Marshal(b) + if bytes.Equal(x, y) { + return "" + } + if sameValue != nil { + sa, okA := a.(string) + sb, okB := b.(string) + if !okA { + sa = string(x) + } + if !okB { + sb = string(y) + } + if same, known := sameValue(sa, sb); known { + if same { + return "" + } + return "" + } + } + return "" +} + +// canonicalJSON is raw read as one JSON value (numbers kept as written) and written again, so equal values compare equal. +func canonicalJSON(raw json.RawMessage) (json.RawMessage, error) { + var v any + d := json.NewDecoder(bytes.NewReader(raw)) + d.UseNumber() + if err := d.Decode(&v); err != nil { + return nil, err + } + if _, err := d.Token(); err != io.EOF { + return nil, errors.New("more after the value") + } + return json.Marshal(v) +} + +func jsonEqual(a, b any) bool { + ja, _ := json.Marshal(a) + jb, _ := json.Marshal(b) + return string(ja) == string(jb) +} diff --git a/internal/compositions/composed_seals.go b/internal/compositions/composed_seals.go new file mode 100644 index 0000000..a3374da --- /dev/null +++ b/internal/compositions/composed_seals.go @@ -0,0 +1,195 @@ +package compositions + +import ( + "bytes" + "encoding/json" + "git.novox.be/novox/mesh-sdk/go/delivery" + "sort" + "strconv" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// Seals is, by slot, each value one composition carries sealed to its node's key (issue 1126, C1): a file's `sealed` +// a vault box (catalogue.VaultSealedField) and each entry of a file's secrets (SecretsField), as their JSON text (a box is carried as its JSON, a sealed value as +// the string it is). A slot is one field of one resource of the composition's module (SealSlot): a box is reused only +// at the slot that holds it, never for another resource whose value opens to the same (a provider's grant and its +// consumer's secret on one node hold one value). +type Seals = map[string]string + +// SealedFields are the fields of a resource that carry a value sealed to the node's key. +var SealedFields = []string{"sealed", catalogue.VaultSealedField} + +// SealSlot is the slot of one sealed field of one resource, by the resource's id (its place, `#`, when it has none). +// The module is the composition's: a slot is never compared across compositions. +func SealSlot(resource, field string) string { return resource + "#" + field } + +// SecretsField is the field of a file that carries, by name, each sealed value its content asks for +// (${secret:}, catalogue's intoFile): each entry is a slot of its own (SecretSlotField), so a secret sealed again +// is judged by the value it opens to, entry by entry, like a file's `sealed` (issue 1126, C1; the seed-7 probe: about 45 +// files on ace and novox differed by their secrets' bytes alone). +const SecretsField = "secrets" + +// SecretSlotField is the field part of the slot of one entry of a file's secrets (SealSlot(id, SecretSlotField(name))). +func SecretSlotField(name string) string { return SecretsField + "." + name } + +// sealAt is one sealed value of a resource: its slot's field, its place (a top-level field, or an entry of the +// secrets), and its raw JSON. +type sealAt struct { + field, entry string + raw json.RawMessage +} + +// sealsAt is every sealed value of one resource's fields, in a fixed order: SealedFields, then the secrets by name. +func sealsAt(fields map[string]json.RawMessage) []sealAt { + var out []sealAt + for _, field := range SealedFields { + if f, has := fields[field]; has { + out = append(out, sealAt{field: field, raw: f}) + } + } + var secrets map[string]json.RawMessage + if f, has := fields[SecretsField]; has && json.Unmarshal(f, &secrets) == nil { + names := make([]string, 0, len(secrets)) + for name := range secrets { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + out = append(out, sealAt{field: SecretSlotField(name), entry: name, raw: secrets[name]}) + } + } + return out +} + +// SealsIn is every sealed value c carries, by slot; nil when it carries none. +func SealsIn(c delivery.Composed) Seals { + var out Seals + for i, raw := range c.Resources { + id, fields, ok := sealedFieldsOf(raw, i) + if !ok { + continue + } + for _, s := range sealsAt(fields) { + if text := sealedText(s.raw); text != "" { + if out == nil { + out = Seals{} + } + out[SealSlot(id, s.field)] = text + } + } + } + return out +} + +// KeepSeals is c with each sealed value replaced by the one kept at the same slot, where same says the two open to the +// same value (known and same): a value the vault sealed again is then the box that runs, so the composition is the +// one that runs (ADR 0547 §3, issue 1126). A slot kept holds nothing for, or whose value differs or cannot be opened, +// keeps c's. Every other byte of a resource is kept as it is; c itself is not changed. +func KeepSeals(c delivery.Composed, kept Seals, same func(a, b string) (same, known bool)) delivery.Composed { + if len(kept) == 0 || same == nil { + return c + } + out := c + out.Resources = nil + for i, raw := range c.Resources { + id, fields, ok := sealedFieldsOf(raw, i) + if ok { + for _, s := range sealsAt(fields) { + ours := sealedText(s.raw) + theirs, held := kept[SealSlot(id, s.field)] + if !held || ours == "" || theirs == ours { + continue + } + if opens, known := same(ours, theirs); !known || !opens { + continue + } + value := json.RawMessage(theirs) + if s.raw[0] == '"' { + value, _ = json.Marshal(theirs) + } + if s.entry == "" { + if swapped, ok := withField(raw, s.field, value); ok { + raw = swapped + } + continue + } + // An entry of the secrets: swapped inside the secrets as they stand in raw, then the secrets in raw. + var now map[string]json.RawMessage + if json.Unmarshal(raw, &now) != nil { + continue + } + if secrets, ok := withField(now[SecretsField], s.entry, value); ok { + if swapped, ok := withField(raw, SecretsField, secrets); ok { + raw = swapped + } + } + } + } + out.Resources = append(out.Resources, raw) + } + return out +} + +// sealedFieldsOf is a resource's id (`#` when it has none) and its top-level fields, false when it is no object. +func sealedFieldsOf(raw json.RawMessage, i int) (string, map[string]json.RawMessage, bool) { + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil { + return "", nil, false + } + var id string + if f, ok := fields["id"]; !ok || json.Unmarshal(f, &id) != nil || id == "" { + id = "#" + strconv.Itoa(i) + } + return id, fields, true +} + +// sealedText is a sealed field as its text: a string as it is, a box as its JSON. +func sealedText(f json.RawMessage) string { + var text string + if json.Unmarshal(f, &text) == nil { + return text + } + var compact bytes.Buffer + if json.Compact(&compact, f) != nil { + return string(f) + } + return compact.String() +} + +// withField is the object raw with its top-level field replaced by value, every other byte as it was (so a composition +// whose sealed values are those that run is the one that runs, byte for byte); false when raw is no object holding it. +func withField(raw json.RawMessage, field string, value json.RawMessage) (json.RawMessage, bool) { + d := json.NewDecoder(bytes.NewReader(raw)) + if t, err := d.Token(); err != nil || t != json.Delim('{') { + return nil, false + } + for d.More() { + t, err := d.Token() + if err != nil { + return nil, false + } + key, _ := t.(string) + before := d.InputOffset() + var skip json.RawMessage + if err := d.Decode(&skip); err != nil { + return nil, false + } + if key != field { + continue + } + after := d.InputOffset() + // The span holds the colon, any space and the value: kept up to the colon, the value replaced. + span := raw[before:after] + colon := bytes.IndexByte(span, ':') + if colon < 0 { + return nil, false + } + out := make([]byte, 0, len(raw)-len(span)+colon+1+len(value)) + out = append(out, raw[:int(before)+colon+1]...) + out = append(out, value...) + out = append(out, raw[after:]...) + return out, true + } + return nil, false +} diff --git a/internal/link/access_act_test.go b/internal/link/access_act_test.go index 17278bb..32baa52 100644 --- a/internal/link/access_act_test.go +++ b/internal/link/access_act_test.go @@ -7,8 +7,8 @@ import ( "testing" "time" - occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient" - oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" stdio "git.novox.be/novox/mesh-sdk/go" ) diff --git a/internal/link/engineseat.go b/internal/link/engineseat.go index 4d47ace..ad7ca02 100644 --- a/internal/link/engineseat.go +++ b/internal/link/engineseat.go @@ -10,7 +10,7 @@ import ( "time" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" - oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/catalogue" "github.com/nats-io/nats.go" diff --git a/internal/link/handacts.go b/internal/link/handacts.go index bb0cf84..6592603 100644 --- a/internal/link/handacts.go +++ b/internal/link/handacts.go @@ -13,9 +13,9 @@ import ( "sync" "time" - occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" - oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -58,6 +58,13 @@ const ( ActSeatListAgain = "seat-list-again" ) +// SeatListCallers are who may call the engine's two seat-list verbs: every operator's session and the healers (novox/hq +// ADR 0636 §7, the node-engine seat's definition: the operator and the healers). +var SeatListCallers = stdio.VerbCallers{ + ActSeatList: {stdio.AnyPerson, "mesh-healers"}, + ActSeatListAgain: {stdio.AnyPerson, "mesh-healers"}, +} + // The states of an act: asked, done and refused are also the seat's events. const ( HandAsked = nodeengine.EventAsked @@ -228,11 +235,17 @@ func (h *HandActs) Call(c EngineCall) (any, error) { } return h.Compose(arg("module")) case ActSeatList: + if err := SeatListCallers.Allowed(c.Verb, c.Caller); err != nil { + return nil, err + } if h.SeatList == nil { return nil, errors.New("this engine reads no seat list yet: its node agent has not started") } return h.SeatList() case ActSeatListAgain: + if err := SeatListCallers.Allowed(c.Verb, c.Caller); err != nil { + return nil, err + } if h.SeatListAgain == nil { return nil, errors.New("this engine reads no seat list yet: its node agent has not started") } diff --git a/internal/link/handover_test.go b/internal/link/handover_test.go index 6dd537a..54665ae 100644 --- a/internal/link/handover_test.go +++ b/internal/link/handover_test.go @@ -264,7 +264,9 @@ func TestALinkThatHearsHandOversIsGivenTheQueues(t *testing.T) { Composition: func(module, digest string, diff bool) (any, error) { return map[string]any{"module": module, "digest": digest, "diff": diff}, nil }, - Compose: func(module string) (any, error) { return map[string]any{"composed": true, "retried": module}, nil }, + Compose: func(module string) (any, error) { return map[string]any{"composed": true, "retried": module}, nil }, + SeatList: func() (any, error) { return map[string]any{"held": true}, nil }, + SeatListAgain: func() (any, error) { return map[string]any{"read": true}, nil }, // ADR 0618 §3: what this engine states it reads, and when it last stated it. ManifestReads: func() ([]string, time.Time) { return []string{"form:a", "seat:b"}, time.Date(2026, 11, 4, 10, 0, 0, 0, time.UTC) @@ -299,6 +301,12 @@ func TestALinkThatHearsHandOversIsGivenTheQueues(t *testing.T) { {"packages", EngineToolSubject("packages", "n1") + " " + `{"write":true}`, `"key":null,"wrote":true`}, {"packages", EngineToolSubject("packages", "n1") + " " + `{"write":"true"}`, `"note":"the engine has written no key for this node yet`}, {"manifest-reads", EngineToolSubject("manifest-reads", "n1"), `"manifest-reads":["form:a","seat:b"],"node":"n1","stated-at":"2026-11-04T10:00:00Z","v":1`}, + // ADR 0636 §7: the seat-list verbs answer every operator's session and the healers, no other module. + {"seat-list", "mesh.seat.node-engine.call.seat-list.n1.person~jochen", `"held":true`}, + {"seat-list-again", "mesh.seat.node-engine.call.seat-list-again.n1.person~jochen", `"read":true`}, + {"seat-list-again", "mesh.seat.node-engine.call.seat-list-again.n1.ace~mesh-healers", `"read":true`}, + {"seat-list-again", "mesh.seat.node-engine.call.seat-list-again.n1.ace~messenger", `not on the holder's caller list for seat-list-again`}, + {"seat-list-again", EngineToolSubject("seat-list-again", "n1"), `no caller the tool runner proved`}, {"unserved", "mesh.seat.node-engine.call.unserved.n1.x", `"error":"the node seat node-engine serves no verb \"unserved\""`}, } { subject, body, ok := strings.Cut(row.subject, " ") diff --git a/internal/link/have.go b/internal/link/have.go index 23757db..213f0d6 100644 --- a/internal/link/have.go +++ b/internal/link/have.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/novox/mesh-host/internal/compositions" "sort" "strings" "sync" @@ -49,7 +50,7 @@ type haveState struct { nodeComposed string // nodeSealed is, on a node composing its own, each value its own composition carries sealed to its key (ADR 0547 §3, // issue 1126): kept in its key so a restart reuses them and applies nothing again. - nodeSealed delivery.Seals + nodeSealed compositions.Seals // nodeCandidates is the node's answer to each placement candidate proposal (novox/hq ADR 0559 §4), written into its key. nodeCandidates map[string]delivery.CandidateAnswer // nodeComposes and nodeComposesWhy are whose compositions the node applies and why (HaveNode.Composes), written into @@ -722,7 +723,7 @@ func (q *Session) NoteRemoved(ctx context.Context, module, build string) { // NoteNode takes the digest of the node's own composition it runs and the values it carries sealed to the node's key // (nil for a composition the node did not make itself), and writes the node's key where it differs. -func (q *Session) NoteNode(ctx context.Context, composed string, sealed delivery.Seals) { +func (q *Session) NoteNode(ctx context.Context, composed string, sealed compositions.Seals) { q.init() s := &q.haves s.mu.Lock() diff --git a/internal/link/have_test.go b/internal/link/have_test.go index 47a3061..d2b9466 100644 --- a/internal/link/have_test.go +++ b/internal/link/have_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/novox/mesh-host/internal/compositions" "slices" "strings" "sync" @@ -602,7 +603,7 @@ func TestAnAppliedModulesHaveNamesTheFingerprintAndTheDigestAndFollowsItsHealth( if got := l.have(t, "n1.web"); got.Phase != delivery.PhaseRemoved { t.Fatalf("removed: %+v", got) } - q.NoteNode(context.Background(), dig, delivery.Seals{"core-box#sealed": "core"}) + q.NoteNode(context.Background(), dig, compositions.Seals{"core-box#sealed": "core"}) if n, err := delivery.DecodeHaveNode(l.kv["n1.node"].value); err != nil || n.Composed != dig || len(n.SealedAt) != 1 || n.SealedAt["core-box#sealed"] != "core" { t.Fatalf("the node key, with its own composition's sealed values by slot: %+v %v", n, err) } diff --git a/internal/link/messages.go b/internal/link/messages.go index 59cebe1..96d0475 100644 --- a/internal/link/messages.go +++ b/internal/link/messages.go @@ -1,6 +1,7 @@ package link import ( + "github.com/novox/mesh-host/internal/compositions" "time" "git.novox.be/novox/mesh-sdk/go/delivery" @@ -644,7 +645,7 @@ type ModuleFact struct { // Kept is what the node keeps of the module as it runs, for a manifest that does not say it yet (delivery.Have.Kept). Kept []string // Sealed is the values the composition it names carries sealed to this node's key, by slot (delivery.Have.SealedAt). - Sealed delivery.Seals + Sealed compositions.Seals // PullWait is, on a failed fact, the pull from a store its apply waits on (delivery.Have.PullWait, issue 1966 ENG-9). PullWait *delivery.PullWait } diff --git a/internal/link/run.go b/internal/link/run.go index 96c9bd8..fd13c56 100644 --- a/internal/link/run.go +++ b/internal/link/run.go @@ -7,7 +7,7 @@ import ( "fmt" "time" - occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" ) // Membership is what a node needs to reach its mesh again, held by the caller. diff --git a/internal/overlay/declaration.go b/internal/overlay/declaration.go deleted file mode 100644 index b248bdd..0000000 --- a/internal/overlay/declaration.go +++ /dev/null @@ -1,187 +0,0 @@ -package overlay - -import ( - "encoding/json" - "fmt" - "strings" -) - -// The overlay is delivered as an ordinary declaration. -// -// novox/hq 08-connectivity: what the host receives is an interface configuration and a peer list, -// as files. It does not compute them and it does not know what a private network is — the shapes -// it already has are enough, which is why connectivity needs nothing new from tier 0. -// -// **No private key travels.** The configuration points at a file the node wrote from a key the -// mesh has never seen, using WireGuard's own ability to set one after the interface is up. So the -// control plane composes a complete configuration for a node it cannot impersonate. - -// Resource is one entry in a declaration, built here and read by the host. -type Resource map[string]any - -// Declaration is what the mesh sends a node to put it on the network. -// -// Three resources and nothing clever: the tools, the configuration, and the interface running. A -// person can read it, which is the point — this is the first thing a node is ever told, and if it -// is wrong the node is unreachable and the mistake has to be findable by eye. -func Declaration(node Node, peers []Peer, keyPath string) ([]byte, error) { - if node.Address == "" { - return nil, fmt.Errorf("%s has no address on the overlay, so there is nothing to configure", - node.Name) - } - if keyPath == "" { - keyPath = DefaultKeyPath - } - - up := Resource{ - "id": "overlay-up", "type": "service", "unit": Unit, - "state": "running", - // Enabled, so the node comes back onto the network after a reboot without waiting to - // be told again. A node whose overlay only exists while something is watching is not - // a node that survives being switched off and on. - "boot": "enabled", - // And restarted when the peer list changes, because a running interface does not - // re-read its configuration. - // - // This is the whole of it: a node joins, every existing node's peer list changes, - // each file is replaced — and without this the service is already running, nothing - // reloads it, and every node keeps a network that no longer matches the mesh. It - // reports complete success. The lab found it the moment a third node arrived. - // - // Declared state rather than a command. The service must reflect the file; the host - // works out that it does not. A command to restart would be an action, and the link - // may not carry one (novox/hq ADR 0005) — the host refused exactly that, correctly, - // which is how this shape was arrived at. - "restart-on": []string{"overlay-config"}, - } - if node.TakesOver != nil { - // The private network takes over the tunnel it found (novox/hq ADR 0105): before this - // unit starts, the host stops and disables the found one — never flushing it — and keeps - // its configuration like any held file. The key is already the found one: the node took - // it as its own overlay key when it enrolled, which is why the mesh's peer list for it - // carries the found peers under the key they know. - up["takes-over"] = map[string]any{ - "interface": node.TakesOver.Interface, - "unit": node.TakesOver.Unit, - "config": node.TakesOver.Config, - } - } - - resources := []Resource{ - { - "id": "overlay-tools", "type": "package", "package": "wireguard-tools", - }, - { - "id": "overlay-config", "type": "file", "path": ConfigPath, - // Readable only by root: it lists every peer's key and endpoint, which is a map of - // the mesh. Not secret in the way a private key is, and not something to leave - // world-readable on a machine somebody else also uses. - "mode": "0600", - "content": config(node, peers, keyPath), - }, - up, - } - - // The names used to be appended here, on the argument that a node with peers and no names is - // half on the network. True, and the wrong place to fix it: names would be identical over a - // different private network, so bundling them with WireGuard made one module out of two - // things. They are their own module now, requiring this one — which is what keeps them - // arriving together without pretending they are the same concern. - return json.Marshal(map[string]any{"declaration": 1, "resources": resources}) -} - -// config writes the interface file. -// -// Deliberately in the order a person would read it: who I am, then who I talk to, each with a -// line saying why it is there. A generated file that cannot be understood by the person it -// confuses is a generated file that gets edited by hand. -func config(node Node, peers []Peer, keyPath string) string { - var b strings.Builder - b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever the\n") - b.WriteString("# peer graph changes, and an edit would survive until the next change and\n") - b.WriteString("# then vanish, which is worse than not being applied at all.\n") - fmt.Fprintf(&b, "#\n# node %s", node.Name) - if node.Site != "" { - fmt.Fprintf(&b, ", at %s", node.Site) - } - if !node.Reachable() { - b.WriteString(", not dialable — it opens every path itself") - } - b.WriteString("\n\n[Interface]\n") - fmt.Fprintf(&b, "Address = %s/32\n", node.Address) - if node.Reachable() { - if port := portOf(node.Endpoint); port != "" { - fmt.Fprintf(&b, "ListenPort = %s\n", port) - } - } else if node.TakesOver != nil && node.TakesOver.Port != 0 { - // Not dialable from the hub, but a LAN peer dials this node on the tunnel it took over, - // so the mesh's interface must listen on that same port (novox/hq: a taken tunnel brings - // its port). Without this the takeover guard refuses overlay-up, and re-placing the node - // with an endpoint — the guard's suggested remedy — breaks a NAT'd node's path. - fmt.Fprintf(&b, "ListenPort = %d\n", node.TakesOver.Port) - } - // The MTU the found tunnel carried, when it set one: a path tuned to 1380 (say) stalls TLS - // and hangs transfers if the mesh's interface comes up at the 1420 default, and no ping shows - // it (novox/hq: a taken tunnel carries its MTU). - if node.TakesOver != nil && node.TakesOver.MTU != 0 { - fmt.Fprintf(&b, "MTU = %d\n", node.TakesOver.MTU) - } - // The private key is set from a file the node wrote, so it never appears here and never - // travelled. Everything else in this file came from the mesh; this one line is the node's. - fmt.Fprintf(&b, "PostUp = wg set %%i private-key %s\n", keyPath) - - if node.Hub { - // A hub carries traffic *between* its spokes, and a Linux machine does not forward - // packets unless it is told to. Without this every spoke reaches the hub perfectly and - // no spoke reaches any other — which is exactly how it failed in the lab, and it looks - // like a peering problem rather than a kernel setting. - // - // Here rather than in a separate resource because it is part of what being a hub means, - // and because it should last exactly as long as the interface does: a machine that stops - // being the hub should stop forwarding, and PostDown below is how that happens. - b.WriteString("PostUp = sysctl -q -w net.ipv4.ip_forward=1\n") - b.WriteString("PostDown = sysctl -q -w net.ipv4.ip_forward=0\n") - - // And past the machine's own firewall, which on any node with a container runtime is - // closed. Docker sets the FORWARD policy to DROP and inserts its chains, so the foundation - // this mesh installs at tier 1 silently breaks the network it builds at tier 2: every - // spoke reaches the hub, no spoke reaches any other, and every part of it reports - // success. Found in the lab; nothing about it is visible from the mesh's own state. - // - // Inserted at the top so it precedes those chains, and removed on the way down so a - // machine that stops being the hub stops carrying other people's traffic. Guarded on - // iptables existing: a machine without it has no policy to get past. - for _, direction := range []string{"-i", "-o"} { - fmt.Fprintf(&b, - "PostUp = command -v iptables >/dev/null && iptables -I FORWARD 1 %s %%i -j ACCEPT || true\n", - direction) - } - for _, direction := range []string{"-i", "-o"} { - fmt.Fprintf(&b, - "PostDown = command -v iptables >/dev/null && iptables -D FORWARD %s %%i -j ACCEPT || true\n", - direction) - } - } - - for _, p := range peers { - fmt.Fprintf(&b, "\n# %s — %s\n[Peer]\n", p.Name, p.Why) - fmt.Fprintf(&b, "PublicKey = %s\n", p.Key) - fmt.Fprintf(&b, "AllowedIPs = %s\n", p.Allowed) - if p.Endpoint != "" { - fmt.Fprintf(&b, "Endpoint = %s\n", p.Endpoint) - } else { - b.WriteString("# no endpoint: this peer cannot be dialled and opens the path itself\n") - } - if p.Keepalive { - b.WriteString("PersistentKeepalive = 25\n") - } - } - return b.String() -} - -func portOf(endpoint string) string { - if i := strings.LastIndex(endpoint, ":"); i >= 0 { - return endpoint[i+1:] - } - return "" -} diff --git a/internal/overlay/declaration_test.go b/internal/overlay/declaration_test.go deleted file mode 100644 index af54dcd..0000000 --- a/internal/overlay/declaration_test.go +++ /dev/null @@ -1,305 +0,0 @@ -package overlay - -import ( - "encoding/json" - "fmt" - "strings" - "testing" -) - -func declarationFor(t *testing.T, node Node, peers []Peer) (string, []map[string]any) { - t.Helper() - raw, err := Declaration(node, peers, "") - if err != nil { - t.Fatal(err) - } - var d struct { - Declaration int `json:"declaration"` - Resources []map[string]any `json:"resources"` - } - if err := json.Unmarshal(raw, &d); err != nil { - t.Fatal(err) - } - if d.Declaration != 1 { - t.Fatalf("declaration version %d", d.Declaration) - } - for _, r := range d.Resources { - if r["type"] == "file" { - return r["content"].(string), d.Resources - } - } - t.Fatal("the declaration has no configuration file in it") - return "", nil -} - -func TestNoPrivateKeyEverTravels(t *testing.T) { - // The property the whole design rests on: the node generated its keypair and kept the private - // half, so the mesh composes a configuration for a node it cannot impersonate. A private key - // appearing here would mean the control plane had one — and a copy of its database would then - // be every node's network identity. - config, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, - []Peer{{Name: "anchor", Key: "HUB", Allowed: "10.42.0.0/16", Endpoint: "198.51.100.1:51820"}}) - - if strings.Contains(config, "PrivateKey") { - t.Error("the configuration carries a PrivateKey line; the mesh must never hold one") - } - if !strings.Contains(config, "private-key "+DefaultKeyPath) { - t.Error("the configuration does not point at the key file the node wrote, so the " + - "interface would come up with no key at all") - } -} - -func TestTheDeclarationUsesOnlyShapesTheHostAlreadyHas(t *testing.T) { - // Connectivity needs nothing new from tier 0, and that is worth holding: the host does not - // know what a private network is, and should not learn. - _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) - - allowed := map[string]bool{"package": true, "file": true, "service": true, - "directory": true, "container": true, "action": true} - for _, r := range resources { - if !allowed[r["type"].(string)] { - t.Errorf("the overlay declaration uses %q, which the host does not have", r["type"]) - } - } -} - -func TestTheInterfaceComesBackAfterAReboot(t *testing.T) { - // A node whose overlay only exists while something is watching is not a node that survives - // being switched off and on — and it would come back unreachable, which is the worst way to - // come back. - _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) - for _, r := range resources { - if r["type"] == "service" { - if r["boot"] != "enabled" { - t.Error("the overlay interface is not enabled at boot") - } - if r["state"] != "running" { - t.Error("the overlay interface is not asked to be running") - } - return - } - } - t.Error("nothing in the declaration brings the interface up") -} - -func TestTheConfigurationIsNotWorldReadable(t *testing.T) { - // It lists every peer's key and endpoint, which is a map of the mesh. Not secret the way a - // private key is, and not something to leave readable on a machine somebody else also uses. - // The peer list specifically, not every file. `/etc/hosts` is in here too and must be - // world-readable, or nothing on the machine resolves anything — a check that swept all files - // would force it to 0600 and break the machine to protect a file that is not secret. - _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) - for _, r := range resources { - if r["id"] == "overlay-config" && r["mode"] != "0600" { - t.Errorf("the peer list is mode %v", r["mode"]) - } - if r["id"] == "mesh-names" && r["mode"] != "0644" { - t.Errorf("the name file is mode %v; nothing on the machine could read it", r["mode"]) - } - } -} - -func TestAPeerThatCannotBeDialledSaysSo(t *testing.T) { - // A [Peer] with no Endpoint is correct and looks like a mistake. Saying why stops somebody - // helpfully adding one that cannot work. - config, _ := declarationFor(t, Node{Name: "anchor", Key: "HUB", Address: "10.42.0.1", - Endpoint: "198.51.100.1:51820", Hub: true}, - []Peer{{Name: "laptop", Key: "PUB", Allowed: "10.42.0.2/32", Why: "routes through this hub"}}) - - if strings.Contains(config, "Endpoint =") { - t.Error("an endpoint was written for a peer that has none") - } - if !strings.Contains(config, "cannot be dialled") { - t.Error("the file does not say why that peer has no endpoint") - } -} - -func TestTheFileSaysNotToEditIt(t *testing.T) { - // It is replaced whenever the graph changes. An edit survives until then and vanishes, which - // is worse than never being applied — the machine works, then stops, and nothing changed - // that anybody remembers. - config, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) - if !strings.Contains(config, "Do not edit") { - t.Error("a generated file does not say it is generated") - } -} - -func TestANodeWithNoAddressIsRefused(t *testing.T) { - // Rather than a configuration with a blank address, which wg-quick would reject on the - // machine, at boot, where the failure is much harder to see. - if _, err := Declaration(Node{Name: "laptop", Key: "PUB"}, nil, ""); err == nil { - t.Fatal("a node with no overlay address was given a configuration") - } -} - -func TestOnlyAReachableNodeListens(t *testing.T) { - // A ListenPort on a node nothing can dial is a port open for no reason. - config, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) - if strings.Contains(config, "ListenPort") { - t.Error("a node that cannot be dialled was told to listen") - } - config, _ = declarationFor(t, Node{Name: "anchor", Key: "HUB", Address: "10.42.0.1", - Endpoint: "198.51.100.1:51820"}, nil) - if !strings.Contains(config, "ListenPort = 51820") { - t.Error("a reachable node does not listen on the port its endpoint names") - } -} - -func TestTheServiceIsRestartedWhenThePeerListChanges(t *testing.T) { - // The fault this exists to catch, found in the lab the moment a third node arrived: a running - // WireGuard interface does not re-read its configuration. The file was replaced, the service - // was already running so nothing reloaded it, and every existing node kept a network that no - // longer matched the mesh — while looking entirely successful. - // - // So the declaration has to verify what the interface is *carrying*, not what the file says. - // - // And it must be declared state rather than a command: the host refuses an action arriving - // over the link (novox/hq ADR 0005), correctly, which is how this shape was arrived at. There - // is a test below that no action is ever in here. - _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, - []Peer{{Name: "anchor", Key: "HUBKEY", Allowed: "10.42.0.0/16"}}) - - for _, r := range resources { - if r["type"] != "service" { - continue - } - reflects := fmt.Sprint(r["restart-on"]) - if !strings.Contains(reflects, "overlay-config") { - t.Errorf("the interface does not restart when its configuration changes: %v", reflects) - } - return - } - t.Error("nothing in the declaration brings the interface up") -} - -func TestTheOverlayDeclarationCarriesNoAction(t *testing.T) { - // The link may not carry an action, and the host refuses a declaration containing one — whole, - // not in part. An overlay declaration with an action in it does not half-apply: it leaves the - // node with no network at all. - _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) - for _, r := range resources { - if r["type"] == "action" { - t.Errorf("the declaration contains an action (%v); the host will refuse the whole "+ - "thing and the node will have no network", r["id"]) - } - } -} - -func TestTheHubForwardsAndNobodyElseDoes(t *testing.T) { - // A hub carries traffic between its spokes, and Linux does not forward packets unless told - // to. Without it every spoke reaches the hub perfectly and no spoke reaches any other — which - // is how it failed in the lab, and it presents as a peering problem rather than a kernel - // setting, so it is worth being sure of. - hub, _ := declarationFor(t, Node{Name: "anchor", Key: "HUB", Address: "10.42.0.1", - Endpoint: "198.51.100.1:51820", Hub: true}, nil) - if !strings.Contains(hub, "ip_forward=1") { - t.Error("the hub does not enable forwarding, so its spokes cannot reach each other") - } - if !strings.Contains(hub, "ip_forward=0") { - t.Error("the hub never stops forwarding; a machine that stops being the hub would keep " + - "passing traffic it is no longer part of") - } - - // And past the machine's own firewall. Any node with a container runtime has FORWARD set to - // DROP by Docker, so enabling ip_forward alone changes nothing — which is exactly how it - // failed, with every spoke reaching the hub and no spoke reaching any other. - // Checked as PostUp specifically. "contains FORWARD" passes on the PostDown line alone, - // which would leave a hub that tears down rules it never put up. - if !strings.Contains(hub, "PostUp = command -v iptables") { - t.Error("the hub does not open its own firewall, so a container runtime's DROP policy " + - "silently eats everything it was supposed to carry") - } - if !strings.Contains(hub, "PostDown = command -v iptables") { - t.Error("the hub never removes those rules, so a machine that stops being the hub keeps " + - "passing traffic it is no longer part of") - } - - spoke, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) - if strings.Contains(spoke, "FORWARD") { - t.Error("a spoke was given forwarding rules it has no use for") - } - if strings.Contains(spoke, "ip_forward") { - t.Error("a spoke was told to forward packets, which is not its job and widens what a " + - "compromised one could do") - } -} - -// novox/hq ADR 0105: a node whose private network takes over the tunnel it found is told so on -// the interface's service, and nothing else about the declaration changes — the key is already -// the found one, taken at enrolment. -func TestTakingOverAFoundTunnelIsSaidOnTheInterfacesService(t *testing.T) { - node := Node{Name: "anchor", Key: "PUB", Address: "192.0.2.1", Hub: true, - Endpoint: "198.51.100.1:51900", - TakesOver: &TakeOver{Interface: "wg0", Unit: "wg-quick@wg0", Config: "/etc/wireguard/wg0.conf"}} - config, resources := declarationFor(t, node, nil) - - var up map[string]any - for _, r := range resources { - if r["type"] == "service" { - up = r - } - } - takes, ok := up["takes-over"].(map[string]any) - if !ok { - t.Fatalf("the interface's service does not say what it takes over: %+v", up) - } - if takes["unit"] != "wg-quick@wg0" || takes["config"] != "/etc/wireguard/wg0.conf" || takes["interface"] != "wg0" { - t.Errorf("the takeover names the wrong tunnel: %+v", takes) - } - if !strings.Contains(config, "ListenPort = 51900") { - t.Errorf("the hub's interface does not listen on the tunnel's port:\n%s", config) - } - if strings.Contains(config, "PrivateKey") { - t.Error("the found key travelled in the configuration; it is the node's own, set from its key file") - } - - _, plain := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "192.0.2.4"}, nil) - for _, r := range plain { - if _, says := r["takes-over"]; says { - t.Error("a node taking over nothing was told to take something over") - } - } -} - -func TestATakenTunnelBringsItsListenPortEvenWhenNotDialable(t *testing.T) { - // A home node behind NAT (no Endpoint, so not Reachable) that took over a tunnel must still - // listen on that tunnel's port, because its LAN peers dial it there (novox/hq: a taken tunnel - // brings its port). Without this the takeover guard refuses overlay-up. - config, _ := declarationFor(t, Node{ - Name: "shanks", Key: "SPOKE", Address: "10.10.0.3", - TakesOver: &TakeOver{Interface: "wg0", Unit: "wg-quick@wg0", Port: 51820}, - }, nil) - - if !strings.Contains(config, "ListenPort = 51820") { - t.Fatalf("a taken tunnel's port must be the mesh interface's ListenPort:\n%s", config) - } - if strings.Contains(config, "Endpoint =") { - t.Error("a node that only listens for LAN peers must not advertise an endpoint") - } -} - -func TestANodeWithNoTunnelAndNoEndpointStillListensOnNothing(t *testing.T) { - // The guard against over-emitting: a plain spoke with neither an endpoint nor a taken tunnel - // writes no ListenPort — it purely dials out. - config, _ := declarationFor(t, Node{Name: "laptop", Key: "K", Address: "10.10.0.9"}, nil) - if strings.Contains(config, "ListenPort") { - t.Fatalf("a dial-only node needs no ListenPort:\n%s", config) - } -} - -// A path tuned to a smaller MTU (1380 here) survives the takeover: the mesh interface comes up with the same MTU, or -// transfers hang silently (novox/hq: a taken tunnel carries its MTU). A tunnel that set none gets no MTU line. -func TestATakenTunnelCarriesItsMTU(t *testing.T) { - for _, c := range []struct { - mtu int - want string - }{{1380, "MTU = 1380"}, {0, ""}} { - config, _ := declarationFor(t, Node{ - Name: "shanks", Key: "SPOKE", Address: "10.10.0.3", - TakesOver: &TakeOver{Interface: "wg0", Port: 51820, MTU: c.mtu}, - }, nil) - if c.want != "" && !strings.Contains(config, c.want) || c.want == "" && strings.Contains(config, "MTU") { - t.Errorf("a tunnel of MTU %d gives:\n%s", c.mtu, config) - } - } -} diff --git a/internal/overlay/generator.go b/internal/overlay/generator.go deleted file mode 100644 index eb56a43..0000000 --- a/internal/overlay/generator.go +++ /dev/null @@ -1,151 +0,0 @@ -package overlay - -import ( - "encoding/json" - "fmt" - "strconv" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// The private network as a module rather than as code beside the module system. -// -// A machine's peer list is derived from every other machine, so it differs on each one and -// changes when any of them changes — there is nothing that could be written in a manifest. So the -// module says its resources are computed, and this computes them. -// -// What that buys, beyond one mechanism instead of two: **a machine is on the private network -// because it was assigned the module.** Before this, every machine with a key and an address was -// on it, and there was no way to say a machine should stay off. -// -// It is worth saying what is *not* here, because networking looks like it should be foundational. -// The host needs none of this. It has an address and a route to the broker before the mesh exists -// — that is the machine's own networking — and the broker's address is carried in the enrolment -// token rather than resolved. So the private network is something the mesh installs on top, like -// anything else, and a node without it is an ordinary node that nothing reaches directly. - -// **A resolver's data went the same way as the names.** Two constants used to sit here — a -// `mesh-resolver` module that would write the machines as wildcards, and a `resolver-data` -// requirement a daemon would ask for. Nothing ever provided or consumed either: a module that -// answers names asks for the `node-zones` fact in its own manifest (catalogue.FactsInto) and -// requires Addressing, since the file is made of the mesh's addresses and means nothing off the -// network. A requirement nothing provides is refused at resolution, so leaving the names here -// would only have documented a mechanism that does not exist. - -// **There is no bundle any more** (novox/hq ADR 0226). A `networking` module requiring this one and -// nothing else used to be what a machine was assigned, so that "get the network working" was one -// word and a second VPN could be chosen by assigning it instead. The mesh has one private network, -// every machine is on it, and the bundle was a second name for this module that every machine -// carried. A machine is assigned Name directly; another VPN is still chosen by assigning it in its -// place, which is all the bundle ever did. - -// Generator answers what one node's network configuration is. -type Generator struct { - nodes []Node - graph Graph - // keyPath is where each node keeps the private half it generated. Named rather than carried: - // the mesh has never seen it and never will. - keyPath string - // No registry. Being on the network is still what grants a machine the right to pull from the - // mesh's artifact store in the clear (novox/hq ADR 0082), but the runtime's file is the runtime's - // module's: the private network writes nothing into it, and that module states the registry - // itself, told where the store is reached by ${seat:mesh-artifact-store:reach} (novox/hq ADR - // 0222, issue 190). -} - -// From builds a generator over the machines that are part of the network. -// -// The nodes given are the ones assigned the module — not every node the mesh knows. A machine -// that was never given it is absent from everybody's peer list and from the names, which is what -// "not on the network" has to mean. -func From(nodes []Node, cidr, keyPath string) (*Generator, error) { - graph, err := Compute(nodes, cidr) - if err != nil { - return nil, err - } - return &Generator{nodes: nodes, graph: graph, keyPath: keyPath}, nil -} - -// Resources is one node's interface, peers and names. -func (g *Generator) Resources(node string) ([]map[string]any, bool, error) { - peers, part := g.graph[node] - if !part { - // Assigned and not yet placed on the network. Ordinary and brief, so it is an answer - // rather than an error. - return nil, false, nil - } - var self Node - for _, n := range g.nodes { - if n.Name == node { - self = n - } - } - - raw, err := Declaration(self, peers, g.keyPath) - if err != nil { - return nil, false, err - } - var parsed struct { - Resources []map[string]any `json:"resources"` - } - if err := json.Unmarshal(raw, &parsed); err != nil { - return nil, false, err - } - return parsed.Resources, true, nil -} - -// Nodes are the machines this generator was built over, so a caller can say who is on the network. -func (g *Generator) Nodes() []Node { return g.nodes } - -// Graph is the peer list per node, for showing. -func (g *Generator) Graph() Graph { return g.graph } - -// Empty is a network nobody is on. -// -// A mesh where no machine was given the module. Legitimate rather than broken — every node still -// reaches the broker, which is what being in the mesh is — so it answers "not part of this" for -// everyone instead of refusing for want of a hub. -func Empty() *Generator { return &Generator{graph: Graph{}} } - -// Listens is the port this node accepts the private network on, which only a hub has. -// -// **A fact about this machine's place in the mesh, not about the module.** Every machine on the -// network runs the same module; a hub is dialled by every node at other sites and needs its port -// open, and a machine that is not a hub dials out and needs nothing open at all. A static field in -// a manifest is one answer for every machine that runs it, so it cannot say this — and the machine -// it would get wrong is the one facing the public internet, which is the machine that most needs -// filtering. -// -// The port is the one in the endpoint, which is also where the interface takes its ListenPort -// from. One source, so a rule set cannot open a port the interface is not on. -func (g *Generator) Listens(node string) ([]catalogue.Listening, error) { - for _, n := range g.nodes { - if n.Name != node { - continue - } - if !n.Reachable() { - // It dials out and nothing dials it. Opening a port here would be opening one on a - // machine nothing connects to, which is not harmless — it is a rule with no source - // that somebody later has to work out the reason for. - return nil, nil - } - port := portOf(n.Endpoint) - if port == "" { - return nil, fmt.Errorf( - "%s is reachable at %q and no port can be read from it, so what it must accept "+ - "the private network on is unknown", node, n.Endpoint) - } - number, err := strconv.Atoi(port) - if err != nil { - return nil, fmt.Errorf("%s is reachable at %q, and %q is not a port", node, n.Endpoint, port) - } - return []catalogue.Listening{{ - Port: number, Protocol: "udp", From: catalogue.FromEverywhere, - // From everywhere, and deliberately: a node at another site is not on the private - // network until this port lets it on, so restricting this to the mesh would be a - // rule that can never be satisfied by the thing it exists for. - Why: "the private network — a node at another site has no other way in", - }}, nil - } - return nil, nil -} diff --git a/internal/overlay/generator_test.go b/internal/overlay/generator_test.go deleted file mode 100644 index 569322b..0000000 --- a/internal/overlay/generator_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package overlay - -import ( - "testing" -) - -// novox/hq ADR 0222, issue 190: the runtime's file and service are the runtime's module's. The -// private network writes nothing into either — being on it still grants the right to pull from the -// mesh's store in the clear (ADR 0082), and the runtime's module states that trust itself. -func TestTheNetworkWritesNothingOfTheRuntimes(t *testing.T) { - nodes := []Node{ - {Name: "anchor", Site: "lab", Hub: true, Endpoint: "192.0.2.10:51820", Key: "k1", Address: "10.42.0.1"}, - {Name: "node2", Site: "lab", Key: "k2", Address: "10.42.0.2"}, - } - g, err := From(nodes, "10.42.0.0/16", "") - if err != nil { - t.Fatal(err) - } - for _, node := range []string{"anchor", "node2"} { - resources, part, err := g.Resources(node) - if err != nil || !part { - t.Fatalf("%s: resources: %v part=%v", node, err, part) - } - for _, r := range resources { - if r["path"] == "/etc/docker/daemon.json" || r["unit"] == "docker.service" { - t.Errorf("%s: the private network declares the runtime's %v", node, r) - } - } - } -} diff --git a/internal/overlay/graph.go b/internal/overlay/graph.go deleted file mode 100644 index 4be1a43..0000000 --- a/internal/overlay/graph.go +++ /dev/null @@ -1,231 +0,0 @@ -// Package overlay computes the private network every node runs on. -// -// novox/hq 08-connectivity. This is control-plane work by definition: a peer list is derived from -// every node at once, and no node has that. A node computes nothing about the mesh — it generates -// a keypair, publishes the public half, and receives the rest. -// -// The shape is a hub, with direct peering between nodes at the same site. Not a full mesh, and -// the reason is a property of WireGuard rather than a preference: there is no failover. A more -// specific route to a dead endpoint blackholes; it does not fall back to the general one. So a -// node gets exactly one path to any peer, because two would mean one of them silently swallowing -// traffic. -package overlay - -import ( - "errors" - "fmt" - "sort" - "strings" -) - -// Node is one machine's place on the network, as the mesh holds it. -type Node struct { - Name string - Key string - Endpoint string - Site string - Hub bool - Address string - - // Carried are the peers of the tunnel this node took over (novox/hq ADR 0105): machines the - // mesh has no record of, each known by the public key and the address the found tunnel routed - // to it. Only a hub has any. They stay in its peer list until a node enrols with that key — - // from then on the node is the peer. - Carried []Carried - // TakesOver names the found tunnel this node's private network replaces: its unit is stopped - // and disabled, never flushed, and its configuration kept, before the mesh's interface comes - // up with the found key. Nil on a node that raises the mesh's interface beside whatever it has. - TakesOver *TakeOver -} - -// Carried is one peer of an adopted tunnel that has not enrolled: a peer of the tunnel, not a -// node of the mesh. -type Carried struct { - Key string - Address string -} - -// TakeOver is the found tunnel a node's private network takes over, as the host is told it. -type TakeOver struct { - Interface string - Unit string - Config string - // MTU is the found tunnel's, when it set one — emitted so a tuned path keeps its MTU. - MTU int - // Port is the port the found tunnel listened on. The mesh's interface must listen on it too, - // even on a node that is not dialable from the hub: a home node's LAN peers dial it there - // (novox/hq: a taken tunnel brings its port). Listening is "a peer dials me here"; it is not - // "the hub can dial me", which is Reachable — the two were conflated. - Port int -} - -// HostPrefix is one address as a route: /32 for IPv4, /128 for IPv6. -func HostPrefix(address string) string { - if strings.Contains(address, ":") { - return address + "/128" - } - return address + "/32" -} - -// CarriedName is how a carried peer is named in a peer list: it has no node name, so it is named -// by the key its packets arrive under. -func CarriedName(key string) string { - short := key - if len(short) > 8 { - short = short[:8] + "…" - } - return "a peer of the tunnel (" + short + ")" -} - -// Reachable reports whether other nodes can dial this one. Declared, never inferred. -func (n Node) Reachable() bool { return strings.TrimSpace(n.Endpoint) != "" } - -// Peer is one entry in a node's peer list. -type Peer struct { - Name string - Key string - // Endpoint is empty when this peer cannot be dialled — it must dial us instead. - Endpoint string - // Allowed is what traffic goes down this tunnel. A single address for a direct peer; the - // whole overlay for the hub, which is what makes it the route of last resort. - Allowed string - // Keepalive matters only on the side behind NAT: a node that cannot be dialled has to keep - // the path open from its end, or the peer's first packet arrives at a mapping that has - // already expired. - Keepalive bool - // Why this peer is in the list, for a person reading a generated file and wondering. - Why string -} - -// Graph is every node's peer list. -type Graph map[string][]Peer - -// ErrNoHub means nobody has said which node is the hub. -// -// Its own error rather than an empty graph: a mesh with no hub has no path between sites, and -// answering with "no peers" would look like a working mesh where nothing can reach anything. -var ErrNoHub = errors.New("this mesh has no hub, so there is no path between sites") - -// Compute derives every node's peer list. -// -// Nodes without a key or an address are skipped rather than refused: a node that has enrolled and -// not yet been given a place on the network is an ordinary in-between state, and failing the whole -// graph because one node is half-configured would mean no node gets a network. -func Compute(nodes []Node, overlayCIDR string) (Graph, error) { - var hub *Node - usable := make([]Node, 0, len(nodes)) - for i := range nodes { - n := nodes[i] - if n.Key == "" || n.Address == "" { - continue - } - usable = append(usable, n) - if n.Hub { - hub = &usable[len(usable)-1] - } - } - if len(usable) == 0 { - return Graph{}, nil - } - if hub == nil { - return nil, ErrNoHub - } - if !hub.Reachable() { - return nil, fmt.Errorf( - "%s is the hub and has no endpoint, so nothing can dial it. The hub is the one node "+ - "that must be reachable from wherever the others are", hub.Name) - } - - graph := Graph{} - for _, self := range usable { - var peers []Peer - - // A node may share a site with the hub, and then the hub is one peer rather than two. - // Written before the loop because it changes what that loop may emit: WireGuard takes one - // entry per public key, so a hub appearing twice is a configuration it refuses — and the - // mesh would have produced it silently. The lab found this on the first two machines that - // shared a site with their hub. - hubIsHere := !self.Hub && self.Site != "" && self.Site == hub.Site - - for _, other := range usable { - if other.Name == self.Name || (hubIsHere && other.Name == hub.Name) { - continue - } - // Two nodes at the same site peer directly — but only if one of them can be - // dialled. If neither can, nobody opens the path, and the direct route is more - // specific than the hub's, so it wins and blackholes. That is this design's own - // stated hazard arriving in it: *a more specific route to a dead endpoint - // blackholes; it does not fall back to the general one.* - // - // Found in the lab with two machines at one site behind no reachable address, which - // is the ordinary shape of a home: they were given each other as peers, neither - // could start, and they could not reach each other at all while both reached the hub - // perfectly. - if self.Site != "" && self.Site == other.Site && (self.Reachable() || other.Reachable()) { - peers = append(peers, Peer{ - Name: other.Name, Key: other.Key, - Endpoint: other.Endpoint, - Allowed: other.Address + "/32", - Keepalive: !self.Reachable(), - Why: "at the same site", - }) - } - } - - if !self.Hub { - // Everything else goes through the hub, including a node that roams. AllowedIPs is - // the whole overlay, so this is the route of last resort — and because direct peers - // above are single addresses, they win on specificity without either being ambiguous. - why := "the hub — everything not at this site" - if hubIsHere { - // One entry doing both jobs: the direct path to a machine that happens to be - // here, and the route to everywhere else. Splitting them would need two entries - // for one key, which is the thing being avoided. - why = "the hub, which is also at this site — everything goes here" - } - peers = append(peers, Peer{ - Name: hub.Name, Key: hub.Key, - Endpoint: hub.Endpoint, - Allowed: overlayCIDR, - Keepalive: !self.Reachable(), - Why: why, - }) - } else { - // The hub holds every node that does not share a site with it, because those nodes - // route through it and it must know where to send the replies. Ones it cannot dial - // will dial it. - enrolled := map[string]bool{} - for _, other := range usable { - enrolled[other.Key] = true - if other.Name == self.Name || (self.Site != "" && self.Site == other.Site) { - continue - } - peers = append(peers, Peer{ - Name: other.Name, Key: other.Key, - Endpoint: other.Endpoint, - Allowed: other.Address + "/32", - Why: "routes through this hub", - }) - } - // And every peer of the tunnel it took over that has not enrolled (novox/hq ADR - // 0105): the same key and the same address the found tunnel had for it, so the - // machine behind it cannot tell the tunnel changed hands. No endpoint — it dials in, - // as it always did. Once a node enrols with that key, the node's entry above is the - // peer, and WireGuard takes one entry per key. - for _, c := range self.Carried { - if enrolled[c.Key] { - continue - } - peers = append(peers, Peer{ - Name: CarriedName(c.Key), Key: c.Key, - Allowed: HostPrefix(c.Address), - Why: "carried from the tunnel this hub took over — a peer of the tunnel, not yet a node of the mesh", - }) - } - } - - sort.Slice(peers, func(i, j int) bool { return peers[i].Name < peers[j].Name }) - graph[self.Name] = peers - } - return graph, nil -} diff --git a/internal/overlay/graph_test.go b/internal/overlay/graph_test.go deleted file mode 100644 index eb8c4e1..0000000 --- a/internal/overlay/graph_test.go +++ /dev/null @@ -1,333 +0,0 @@ -package overlay - -import ( - "errors" - "strings" - "testing" -) - -const cidr = "10.42.0.0/16" - -func at(name, site, address, endpoint string, hub bool) Node { - return Node{Name: name, Key: "key-" + name, Site: site, Address: address, - Endpoint: endpoint, Hub: hub} -} - -func peersOf(t *testing.T, g Graph, node string) map[string]Peer { - t.Helper() - out := map[string]Peer{} - for _, p := range g[node] { - out[p.Name] = p - } - return out -} - -func TestEveryNodeReachesTheHub(t *testing.T) { - // The property that makes this a network at all. Without it a node has no route to anything - // it does not share a site with. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - at("home", "house", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - for _, node := range []string{"laptop", "home"} { - hub, ok := peersOf(t, g, node)["anchor"] - if !ok { - t.Fatalf("%s has no route to the hub", node) - } - if hub.Allowed != cidr { - t.Errorf("%s routes %s through the hub; it must be the whole overlay or the hub is "+ - "not a route of last resort", node, hub.Allowed) - } - } -} - -func TestNodesAtOneSitePeerDirectly(t *testing.T) { - // Machines that share a site have a path that does not need the hub, and using it keeps their - // traffic off a link that may be on another continent. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false), - at("server", "house", "10.42.0.3", "192.0.2.3:51820", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - - direct, ok := peersOf(t, g, "desk")["server"] - if !ok { - t.Fatal("two machines at one site do not peer directly") - } - if direct.Allowed != "10.42.0.3/32" { - t.Errorf("the direct peer allows %s; a single address is what makes it win on "+ - "specificity over the hub's whole-overlay route", direct.Allowed) - } -} - -func TestARoamingNodeGetsExactlyOnePath(t *testing.T) { - // Hub-only, and not as a simplification. WireGuard has no failover: a more specific route to - // a dead endpoint blackholes rather than falling back, so two paths would mean one of them - // silently swallowing traffic. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - at("other", "", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - if got := len(g["laptop"]); got != 1 { - t.Fatalf("a roaming node has %d peers; it gets exactly one path", got) - } - if g["laptop"][0].Name != "anchor" { - t.Errorf("a roaming node's single path is %s, not the hub", g["laptop"][0].Name) - } -} - -func TestTwoRoamingNodesDoNotPeerWithEachOther(t *testing.T) { - // An empty site is not a site. Nodes that roam have no shared location, and treating "" as - // one would have every roaming machine try to dial every other, none of which can be dialled. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - at("phone", "", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - if _, ok := peersOf(t, g, "laptop")["phone"]; ok { - t.Error("two nodes with no site peered as though they shared one") - } -} - -func TestOnlyTheSideThatCannotBeDialledKeepsThePathOpen(t *testing.T) { - // Keepalive matters exactly once: on the node behind NAT. Without it the peer's first packet - // arrives at a mapping that has already expired. On the reachable side it is pointless - // traffic for ever. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - if !peersOf(t, g, "laptop")["anchor"].Keepalive { - t.Error("a node that cannot be dialled does not keep its path open") - } - if peersOf(t, g, "anchor")["laptop"].Keepalive { - t.Error("a reachable node sends keepalives it does not need") - } - - // And between two direct peers at one site, where whether to keep the path open depends on - // which end you are. Checked here because the hub's own peer list happens not to set the - // field at all, so asserting only against the hub tests an absence rather than the rule. - same, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false), - at("server", "house", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - if !peersOf(t, same, "server")["desk"].Keepalive { - t.Error("the peer that cannot be dialled does not keep the path open") - } - if peersOf(t, same, "desk")["server"].Keepalive { - t.Error("the peer that can be dialled sends keepalives it does not need") - } -} - -func TestTheHubKnowsEveryoneItRoutesFor(t *testing.T) { - // The replies have to get back. A hub that does not hold a peer cannot answer it. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - at("home", "house", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - hub := peersOf(t, g, "anchor") - for _, want := range []string{"laptop", "home"} { - if _, ok := hub[want]; !ok { - t.Errorf("the hub does not hold %s, so replies to it have nowhere to go", want) - } - } -} - -func TestANodeWithNoPlaceYetIsSkippedRatherThanFatal(t *testing.T) { - // A node that has enrolled and not yet been given an address is an ordinary in-between state. - // Failing the whole graph over it would mean no node gets a network because one is half done. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - {Name: "newcomer", Key: "", Address: ""}, - }, cidr) - if err != nil { - t.Fatal(err) - } - if _, ok := g["newcomer"]; ok { - t.Error("a node with no key or address was given a peer list") - } - if _, ok := peersOf(t, g, "laptop")["newcomer"]; ok { - t.Error("a node with no key was put in somebody's peer list") - } -} - -func TestAHubAtYourOwnSiteAppearsOnceNotTwice(t *testing.T) { - // WireGuard takes one entry per public key. A hub that shares a site with a spoke was being - // emitted twice — once as a direct peer and once as the route of last resort — producing a - // configuration the interface refuses, from a mesh that thought it had succeeded. - // - // Found in the lab on the first two machines that shared a site with their hub, which is the - // ordinary case for a small mesh and was in none of the tests above. - g, err := Compute([]Node{ - at("anchor", "lab", "10.42.0.1", "192.0.2.10:51820", true), - at("laptop", "lab", "10.42.0.2", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - - seen := map[string]int{} - for _, p := range g["laptop"] { - seen[p.Key]++ - } - for key, count := range seen { - if count > 1 { - t.Errorf("the key %s appears %d times; WireGuard takes one entry per key", key, count) - } - } - - if len(g["laptop"]) != 1 { - t.Fatalf("laptop has %d peers; the hub at its own site is one peer, not two", len(g["laptop"])) - } - // And that single entry has to carry everything, or the node keeps a direct path to the hub - // and loses its route to everywhere else. - if got := g["laptop"][0].Allowed; got != cidr { - t.Errorf("the single entry allows %s; it is both the direct path and the route of last "+ - "resort, so it carries the whole overlay", got) - } - // And nobody peers with themselves, here or in a site of two dialable nodes. - other, err := Compute([]Node{ - at("anchor", "house", "10.42.0.1", "198.51.100.1:51820", true), - at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - for _, graph := range []Graph{g, other} { - for node, peers := range graph { - for _, p := range peers { - if p.Name == node { - t.Errorf("%s peers with itself", node) - } - } - } - } -} - -func TestTwoUnreachableNodesAtOneSiteDoNotPeerDirectly(t *testing.T) { - // Nobody would open the path, and the direct route is more specific than the hub's — so it - // wins and blackholes. Both nodes would reach the hub perfectly and be unable to reach each - // other, which is the worst arrangement available: it looks configured and is not. - // - // This is the design's own warning arriving in its implementation, and the lab found it with - // two machines at one site behind no reachable address — the ordinary shape of a house. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("desk", "house", "10.42.0.2", "", false), - at("server", "house", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - - if _, ok := peersOf(t, g, "desk")["server"]; ok { - t.Error("two nodes that neither can be dialled were peered directly; neither could open " + - "the path and the route is more specific than the hub's, so it blackholes") - } - // And they must still be able to reach each other — through the hub. - if hub := peersOf(t, g, "desk")["anchor"]; hub.Allowed != cidr { - t.Errorf("desk's route to everything else allows %s", hub.Allowed) - } -} - -func TestOneReachableNodeIsEnoughToPeerDirectly(t *testing.T) { - // The other side of it: if either end can be dialled, the path can be opened, and using it - // keeps their traffic off a hub that may be on another continent. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false), - at("server", "house", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - if _, ok := peersOf(t, g, "server")["desk"]; !ok { - t.Error("a node did not peer with a reachable neighbour at its own site") - } - if _, ok := peersOf(t, g, "desk")["server"]; !ok { - t.Error("the reachable node did not hold its unreachable neighbour, so replies have " + - "nowhere to go") - } -} - -// novox/hq ADR 0105: a hub that took over the predecessor's tunnel carries every peer that tunnel -// had, under the key and at the address the peer knows, until a node enrols with that key. -func TestTheHubCarriesTheTunnelsPeersUntilTheyEnrol(t *testing.T) { - hub := at("anchor", "hosting", "192.0.2.1", "198.51.100.1:51900", true) - hub.Carried = []Carried{ - {Key: "key-home", Address: "192.0.2.2"}, - {Key: "key-workstation", Address: "192.0.2.3"}, - } - // The machine behind key-home enrolled: it is a node now, at the address it kept. - home := at("home", "house", "192.0.2.2", "", false) - home.Key = "key-home" - - g, err := Compute([]Node{hub, home}, "192.0.2.0/24") - if err != nil { - t.Fatal(err) - } - peers := peersOf(t, g, "anchor") - carried, ok := peers[CarriedName("key-workstation")] - if !ok { - t.Fatalf("the hub does not carry the peer that has not enrolled: %+v", g["anchor"]) - } - if carried.Allowed != "192.0.2.3/32" || carried.Endpoint != "" || carried.Key != "key-workstation" { - t.Errorf("a carried peer is not the tunnel's own entry — same key, its one address, no endpoint: %+v", carried) - } - if _, twice := peers[CarriedName("key-home")]; twice { - t.Error("a peer that enrolled is carried as well as listed as a node: WireGuard takes one entry per key") - } - if node, ok := peers["home"]; !ok || node.Key != "key-home" || node.Allowed != "192.0.2.2/32" { - t.Errorf("the enrolled peer is not the node it became: %+v", node) - } - // Carried peers are the hub's business only: a spoke routes everything through the hub. - if _, leaked := peersOf(t, g, "home")[CarriedName("key-workstation")]; leaked { - t.Error("a carried peer appeared in a spoke's peer list") - } -} - -// A mesh whose hub cannot carry the paths between sites is refused, never answered as an empty graph: with no hub there -// is no path between sites, and a hub with no endpoint is a peer every node is given and none can reach. Either would -// look configured and be silent, which is how the old arrangement failed when nobody knew the address convention. -func TestAHubThatCannotCarryThePathsIsRefused(t *testing.T) { - for _, c := range []struct { - name string - nodes []Node - says func(error) bool - }{ - {"no hub", []Node{at("a", "", "10.42.0.1", "", false), at("b", "", "10.42.0.2", "", false)}, - func(err error) bool { return errors.Is(err, ErrNoHub) }}, - {"a hub with no endpoint", []Node{at("anchor", "datacentre", "10.42.0.1", "", true), at("laptop", "", "10.42.0.2", "", false)}, - func(err error) bool { return err != nil && strings.Contains(err.Error(), "must be reachable") }}, - } { - if _, err := Compute(c.nodes, cidr); !c.says(err) { - t.Errorf("%s: %v", c.name, err) - } - } -} diff --git a/internal/overlay/names.go b/internal/overlay/names.go deleted file mode 100644 index 3aba422..0000000 --- a/internal/overlay/names.go +++ /dev/null @@ -1,30 +0,0 @@ -package overlay - -import ( - sdk "git.novox.be/novox/mesh-sdk/go/overlay" -) - -// The private network's names and the shape of its module stay in mesh-sdk's overlay package, which more parts read -// (novox/hq ADR 0593 §1); the generator of each node's network configuration is the node-engine's own, here. These -// are those names, so the generator and the composition read one package. -const ( - Requirement = sdk.Requirement - Name = sdk.Name - Addressing = sdk.Addressing - TheNetwork = sdk.TheNetwork - SuffixVar = sdk.SuffixVar - DefaultSuffix = sdk.DefaultSuffix - Interface = sdk.Interface - ConfigPath = sdk.ConfigPath - Unit = sdk.Unit - DefaultKeyPath = sdk.DefaultKeyPath -) - -// Manifest is the module the mesh provides for itself (sdk.Manifest). -func Manifest() map[string]any { return sdk.Manifest() } - -// Suffix is the internal names' suffix (sdk.Suffix). -func Suffix() string { return sdk.Suffix() } - -// InternalName is a node's internal name (sdk.InternalName). -func InternalName(node string) string { return sdk.InternalName(node) } diff --git a/internal/overlay/opens_test.go b/internal/overlay/opens_test.go deleted file mode 100644 index 7abf1bd..0000000 --- a/internal/overlay/opens_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package overlay - -import ( - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -func networkOf(t *testing.T, nodes []Node) *Generator { - t.Helper() - made, err := From(nodes, "10.42.0.0/16", "/var/lib/mesh-host/overlay.key") - if err != nil { - t.Fatal(err) - } - return made -} - -// A hub is dialled by every node at other sites, and needs its port open. A machine that is not a -// hub dials out and needs nothing open at all. -// -// They are the same module, which is why a static field in a manifest cannot say it — and the -// machine it gets wrong is the one facing the public internet, which is the machine that most -// needs filtering. -func TestOnlyAMachineThatCanBeDialledOpensTheOverlaysPort(t *testing.T) { - network := networkOf(t, []Node{ - {Name: "anchor", Key: "a", Endpoint: "198.51.100.10:51820", Site: "one", Hub: true}, - {Name: "laptop", Key: "b", Site: "one"}, - }) - - opens, err := network.Listens("anchor") - if err != nil { - t.Fatal(err) - } - if len(opens) != 1 { - t.Fatalf("the machine every other one dials opens %d ports", len(opens)) - } - if opens[0].Port != 51820 || opens[0].At() != "udp" { - t.Fatalf("the port is not the one the interface listens on: %+v", opens[0]) - } - // From everywhere, and deliberately: a node at another site is not on the private network - // until this port lets it on, so restricting it to the mesh would be a rule that can never be - // satisfied by the thing it exists for. - if opens[0].From != catalogue.FromEverywhere { - t.Fatalf("the way onto the private network is restricted to the private network: %+v", opens[0]) - } - if opens[0].Why == "" { - t.Fatal("a port is opened and nothing says why, which is what makes a rule set unreadable") - } - - // And the machine nothing dials opens nothing. Not harmless to get wrong: a rule with no - // reason is one somebody later has to work out the reason for. - quiet, err := network.Listens("laptop") - if err != nil { - t.Fatal(err) - } - if len(quiet) != 0 { - t.Fatalf("a machine nothing dials opened %d port(s)", len(quiet)) - } -} - -// The port comes from the endpoint, which is where the interface takes its ListenPort from. One -// source, so a rule set cannot open a port the interface is not on. -func TestTheOpenedPortIsTheOneTheInterfaceListensOn(t *testing.T) { - network := networkOf(t, []Node{ - {Name: "anchor", Key: "a", Endpoint: "198.51.100.10:60000", Site: "one", Hub: true}, - }) - opens, err := network.Listens("anchor") - if err != nil { - t.Fatal(err) - } - if len(opens) != 1 || opens[0].Port != 60000 { - t.Fatalf("the rule set would open a port the interface is not on: %+v", opens) - } - - written, err := Declaration(Node{Name: "anchor", Key: "a", Address: "10.42.0.1", - Endpoint: "198.51.100.10:60000", Hub: true}, nil, "/k") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(written), "ListenPort = 60000") { - t.Fatalf("the interface and the rule set disagree about the port:\n%s", written) - } -} - -// An endpoint with no port is refused rather than treated as a machine that opens nothing. -// -// A generator that cannot say what a machine opens is not one that says it opens nothing, and -// closing a port on the evidence of a failure to look is how a machine is severed by a fault -// somewhere else entirely. -func TestAnEndpointWithNoPortIsRefusedRatherThanTakenAsSilence(t *testing.T) { - network := networkOf(t, []Node{ - {Name: "anchor", Key: "a", Endpoint: "198.51.100.10", Site: "one", Hub: true}, - }) - if _, err := network.Listens("anchor"); err == nil { - t.Fatal("a machine whose port could not be read was treated as opening nothing") - } -} - -// A machine the network has never heard of opens nothing, and that is an answer rather than an -// error: a node assigned the module before it is placed is in exactly that state. -func TestAMachineNotOnTheNetworkOpensNothing(t *testing.T) { - network := networkOf(t, []Node{ - {Name: "anchor", Key: "a", Endpoint: "198.51.100.10:51820", Site: "one", Hub: true}, - }) - opens, err := network.Listens("a-stranger") - if err != nil { - t.Fatal(err) - } - if len(opens) != 0 { - t.Fatalf("a machine not on the network opened %d port(s)", len(opens)) - } -} diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index 0bc5d21..e9e9c0f 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -19,6 +19,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/novox/mesh-host/internal/compositions" "maps" "reflect" "slices" @@ -28,9 +29,9 @@ import ( "sync/atomic" "time" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/overlay" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/frombus" "github.com/novox/mesh-host/internal/compose/resolution" @@ -66,7 +67,7 @@ type Env struct { Refused func(why string, held map[string]string) // Running is what the node runs now (agent.Everything), and Unseal opens a value sealed to this machine: a secret // the vault seals again for each ask is kept as the box the node already runs at the same slot (the same field of - // the same resource of the same module, delivery.KeepSeals) when that box opens to the same value, so neither the + // the same resource of the same module, compositions.KeepSeals) when that box opens to the same value, so neither the // switch to composing its own nor a restart changes a composition (ADR 0547 §3); a box held at another slot is // never taken, though it opens to the same value (issue 1126, C1: a provider's grant and its consumer's secret). // Either nil keeps the vault's box. @@ -77,7 +78,7 @@ type Env struct { // (delivery.Have.SealedAt); legacy is the list a have written before slots holds (delivery.Have.Sealed), reused // only within its own module. // An error is a have the bus did not answer for, never one absent: the pass is then tried again (BusFailed). - Held func(module string) (slots delivery.Seals, legacy []string, err error) + Held func(module string) (slots compositions.Seals, legacy []string, err error) // Kept is told, after each pass that composed, what the node keeps of each module as it runs because its manifest does // not yet say it (ADR 0601 §4), by module, so each module's have carries it (agent.KeptNow). Nil tells nobody. Kept func(kept map[string][]string) @@ -1093,7 +1094,7 @@ func (c *Composer) heldNow() []string { } // keepRunning is comp with each sealed value the node already runs at the same slot kept, where it opens to the same -// value (delivery.KeepSeals, issue 1126, C1): of what runs (running, the agent's), else of what its module's have says +// value (compositions.KeepSeals, issue 1126, C1): of what runs (running, the agent's), else of what its module's have says // it runs (Held). A have written before slots names no slot: its boxes are reused within their own module alone, at a // slot no box is kept for, and only one opening to the slot's value. func (c *Composer) keepRunning(comp delivery.Composed, running []delivery.Composed) delivery.Composed { @@ -1104,7 +1105,7 @@ func (c *Composer) keepRunning(comp delivery.Composed, running []delivery.Compos if module == "" { module = agent.NodeModule } - kept := delivery.Seals{} + kept := compositions.Seals{} var legacy []string if c.env.Held != nil { slots, old, err := c.env.Held(module) @@ -1116,12 +1117,12 @@ func (c *Composer) keepRunning(comp delivery.Composed, running []delivery.Compos } for _, r := range running { if r.Module == comp.Module { - for slot, box := range delivery.SealsIn(r) { + for slot, box := range compositions.SealsIn(r) { kept[slot] = box } } } - for slot, box := range delivery.SealsIn(comp) { + for slot, box := range compositions.SealsIn(comp) { if _, ok := kept[slot]; ok { continue } @@ -1132,7 +1133,7 @@ func (c *Composer) keepRunning(comp delivery.Composed, running []delivery.Compos } } } - return delivery.KeepSeals(comp, kept, c.sameValue) + return compositions.KeepSeals(comp, kept, c.sameValue) } // waitLog is what a composer waited on: how many waits ended, the longest and what it was, and the last. @@ -1769,10 +1770,14 @@ func mapValues(m map[string]delivery.Composed) []delivery.Composed { // WaitsForTheSeatList is why a module is held while the node has not read the seat list. const WaitsForTheSeatList = "the node has not read the seat list, so it composes nothing new of this module and keeps what runs of it (novox/hq ADR 0636 §1)" +// WaitsForTheSeatListOwn is why the node's own composition is held: it folds modules and accounts whose seats the node +// cannot judge before the list was read, so only the last applied one runs (ADR 0636 §1). +const WaitsForTheSeatListOwn = "the node has not read the seat list, so it composes nothing new of itself and keeps what runs (novox/hq ADR 0636 §1)" + // holdUntilListed takes out of compositions every module's composition that is new (no running composition has its // digest) while the reader may not act on its seats, and names the module in held: it keeps running what it runs, and -// the pass after the list was read hands it. The node's own composition and a module whose composition runs already are -// left as they are. +// the pass after the list was read hands it. The node's own composition is held the same way, whatever it folds, under +// the scope agent.NodeModule; a composition that runs already is left as it is. func (c *Composer) holdUntilListed(ctx context.Context, r reader, compositions map[string]delivery.Composed, held map[string]string, running []delivery.Composed) map[string]string { if c.env.SeatList == nil { return held @@ -1784,21 +1789,24 @@ func (c *Composer) holdUntilListed(ctx context.Context, r reader, compositions m } } for key, comp := range compositions { - if comp.Module == "" { - continue - } if dig, err := delivery.ComposedDigestOf(comp); err == nil && ran[comp.Module+"/"+dig] { continue } - if c.env.SeatList.MayAct(c.seatsOf(ctx, r, comp.Module)...) { + module, why := comp.Module, WaitsForTheSeatList + if module == "" { // the node's own: no seat to judge, so while the list is unread it waits whole + if c.env.SeatList.MayAct() { + continue + } + module, why = agent.NodeModule, WaitsForTheSeatListOwn + } else if c.env.SeatList.MayAct(c.seatsOf(ctx, r, module)...) { continue } delete(compositions, key) if held == nil { held = map[string]string{} } - if _, already := held[comp.Module]; !already { - held[comp.Module] = WaitsForTheSeatList + if _, already := held[module]; !already { + held[module] = why } } return held diff --git a/internal/selfcompose/selfcompose_test.go b/internal/selfcompose/selfcompose_test.go index 542667a..eef2e64 100644 --- a/internal/selfcompose/selfcompose_test.go +++ b/internal/selfcompose/selfcompose_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/novox/mesh-host/internal/compositions" "os" "reflect" "slices" @@ -18,8 +19,8 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/overlay" "github.com/nats-io/nats.go" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/frombus" @@ -826,7 +827,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { if err != nil { t.Fatal(err) } - sealed := delivery.SealsIn(c) + sealed := compositions.SealsIn(c) if len(sealed) != 1 { t.Fatalf("keeper's composition carries %v", sealed) } @@ -839,20 +840,20 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { _, beforeBox := boxOf(before) for _, row := range []struct { name, in, held string - slots delivery.Seals + slots compositions.Seals kept bool fails bool // the have's read the bus did not answer: no composition, made again later (2026-10-28) }{ - {"the have holds the rotated value at its slot", "keeper", rotatedBox, delivery.Seals{slot: rotatedBox}, true, false}, - {"the have holds the value before the rotation", "keeper", beforeBox, delivery.Seals{slot: beforeBox}, false, false}, + {"the have holds the rotated value at its slot", "keeper", rotatedBox, compositions.Seals{slot: rotatedBox}, true, false}, + {"the have holds the value before the rotation", "keeper", beforeBox, compositions.Seals{slot: beforeBox}, false, false}, {"a have written before slots holds the rotated value", "keeper", rotatedBox, nil, true, false}, - {"another module holds the same value", "lemurs", rotatedBox, delivery.Seals{"lemurs.grant-x#sealed": rotatedBox}, false, false}, - {"another resource of keeper holds the same value", "keeper", rotatedBox, delivery.Seals{"keeper.other#sealed": rotatedBox}, false, false}, + {"another module holds the same value", "lemurs", rotatedBox, compositions.Seals{"lemurs.grant-x#sealed": rotatedBox}, false, false}, + {"another resource of keeper holds the same value", "keeper", rotatedBox, compositions.Seals{"keeper.other#sealed": rotatedBox}, false, false}, {"the bus does not answer for the have", "keeper", rotatedBox, nil, false, true}, } { restarted := env restarted.Running = func() []delivery.Composed { return nil } - restarted.Held = func(module string) (delivery.Seals, []string, error) { + restarted.Held = func(module string) (compositions.Seals, []string, error) { if module != row.in { return nil, nil, nil } @@ -929,13 +930,13 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { } return nil, fmt.Errorf("not sealed to n1: %s", box) } - coreEnv.Held = func(module string) (delivery.Seals, []string, error) { + coreEnv.Held = func(module string) (compositions.Seals, []string, error) { if module == agent.NodeModule { - return delivery.Seals{"core-box#sealed": rotatedBox}, nil, nil + return compositions.Seals{"core-box#sealed": rotatedBox}, nil, nil } return nil, nil, nil } - if got := delivery.SealsIn(New(coreEnv).keepRunning(ownComp, nil))["core-box#sealed"]; got != rotatedBox { + if got := compositions.SealsIn(New(coreEnv).keepRunning(ownComp, nil))["core-box#sealed"]; got != rotatedBox { t.Errorf("the node's own core box after a restart is %s, want the one its key holds %s", got, rotatedBox) } // Issue 1126, C1, the seed-7 probe: each entry of a file's secrets (catalogue's intoFile, the shape captured from it @@ -953,20 +954,20 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { } for _, row := range []struct { name string - held delivery.Seals + held compositions.Seals want string }{ - {"the box that runs at its entry", delivery.Seals{"grafana.ini#secrets.postgres-database": rotatedBox}, rotatedBox}, - {"a box at another entry", delivery.Seals{"grafana.ini#secrets.secret-key": rotatedBox}, "box-again"}, + {"the box that runs at its entry", compositions.Seals{"grafana.ini#secrets.postgres-database": rotatedBox}, rotatedBox}, + {"a box at another entry", compositions.Seals{"grafana.ini#secrets.secret-key": rotatedBox}, "box-again"}, } { secretsEnv := coreEnv - secretsEnv.Held = func(module string) (delivery.Seals, []string, error) { + secretsEnv.Held = func(module string) (compositions.Seals, []string, error) { if module == "grafana" { return row.held, nil, nil } return nil, nil, nil } - got := delivery.SealsIn(New(secretsEnv).keepRunning(ini("box-again", "box-other"), nil)) + got := compositions.SealsIn(New(secretsEnv).keepRunning(ini("box-again", "box-other"), nil)) if got["grafana.ini#secrets.postgres-database"] != row.want || got["grafana.ini#secrets.secret-key"] != "box-other" { t.Errorf("%s: the secrets kept are %v", row.name, got) } @@ -1009,7 +1010,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { } // What runs at the credential's slot is kept, after the composition (keepRunning, issue 1126, C1). webComp := delivery.Composed{Node: "n1", Module: "web", Resources: []json.RawMessage{json.RawMessage(`{"id":"web.secret-pg","sealed":"{\"c\":1}"}`)}} - if got := delivery.SealsIn(pc.keepRunning(webComp, pairEnv.Running()))["web.secret-pg#sealed"]; got != `{"run":1}` { + if got := compositions.SealsIn(pc.keepRunning(webComp, pairEnv.Running()))["web.secret-pg#sealed"]; got != `{"run":1}` { t.Errorf("web's credential composed as %s, want the box that runs", got) } // A node that cannot compose tells the agent once, and once again when it composes (the agent's haves say it). @@ -1812,17 +1813,25 @@ func TestAnUnreadSeatListHoldsWhatIsNewAndHandsItAtTheRead(t *testing.T) { t.Errorf("the node handed %s before it read the seat list", k) } } - if !handed["n1.node"] { - t.Errorf("the node's own composition was held with the module: %v", handed) + // The node's own composition folds modules and accounts it cannot judge before the read: held whole (the ruling of + // ADR 0636 §1), under the scope agent.NodeModule. + mu.Lock() + ownWhy := heldBy[agent.NodeModule] + mu.Unlock() + if ownWhy != WaitsForTheSeatListOwn || handed["n1.node"] { + t.Errorf("the node's own composition is held with %q and handed=%v, want %q and not handed: %v", ownWhy, handed["n1.node"], WaitsForTheSeatListOwn, handed) } - // The reader reads the list and says so: the module is handed, with no timer. + // The reader reads the list and says so: the module and the node's own are handed, with no timer. g.open.Store(true) changed <- struct{}{} - for { + seen := map[string]bool{} + for !(seen["lemurs"] && seen["node"]) { u := next(t, out) - if strings.Contains(u.Key, "lemurs") && !u.Deleted { - break + if u.Deleted { + continue } + seen["lemurs"] = seen["lemurs"] || strings.Contains(u.Key, "lemurs") + seen["node"] = seen["node"] || u.Key == "n1.node" } mu.Lock() still := heldBy["lemurs"]