From 5a7ec3775b988e315a22959e640a9ace299a5cef Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Thu, 15 Oct 2026 21:13:28 +0000 Subject: [PATCH] Land feature 57's node-engine work on its release branch The node-engine renders the feature's templates, collections and moved providers' files, on the SDK the feature's modules build on (feature 57, config and provisioning). --- cmd/mesh-host/main.go | 24 ++- cmd/mesh-host/movesecret.go | 131 +++++++++++++++++ cmd/mesh-host/movesecret_test.go | 134 +++++++++++++++++ cmd/mesh-host/nodekey.go | 62 ++++++++ cmd/mesh-host/nodekey_test.go | 112 ++++++++++++++ cmd/mesh-host/units_test.go | 10 +- cmd/mesh-host/vaultbox_nats_test.go | 154 ++++++++++++++++++++ examples/foundation-first-node.lock | 2 +- go.mod | 2 +- go.sum | 8 +- internal/accounts/accounts.go | 2 +- internal/apply/apply.go | 26 +++- internal/apply/filled.go | 26 +++- internal/apply/filled_apply_test.go | 218 ++++++++++++++++++++++++++++ internal/apply/filled_test.go | 7 +- internal/apply/vault_sealed_test.go | 112 ++++++++++++++ internal/declaration/declaration.go | 22 ++- internal/identity/sealing.go | 64 ++++++++ internal/link/handover.go | 9 +- internal/link/hearing_nats.go | 23 +++ internal/link/messages.go | 22 +++ internal/link/movesecret.go | 209 ++++++++++++++++++++++++++ internal/link/movesecret_test.go | 141 ++++++++++++++++++ internal/link/queue.go | 7 +- internal/link/run.go | 4 + internal/liveness/liveness_test.go | 53 +++++++ internal/liveness/runtime.go | 21 ++- internal/system/system.go | 21 +++ internal/units/exec.go | 14 +- internal/units/units.go | 60 ++++++-- internal/units/units_test.go | 117 +++++++++++++++ 31 files changed, 1774 insertions(+), 43 deletions(-) create mode 100644 cmd/mesh-host/movesecret.go create mode 100644 cmd/mesh-host/movesecret_test.go create mode 100644 cmd/mesh-host/nodekey.go create mode 100644 cmd/mesh-host/nodekey_test.go create mode 100644 cmd/mesh-host/vaultbox_nats_test.go create mode 100644 internal/apply/filled_apply_test.go create mode 100644 internal/apply/vault_sealed_test.go create mode 100644 internal/link/movesecret.go create mode 100644 internal/link/movesecret_test.go diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index f7d7dc1..e8b3b3f 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -1130,7 +1130,7 @@ func runLink(ctx context.Context, opts options) error { // The one liveness judge on this machine (novox/hq ADR 0240), made before anything applies: every // report this host makes carries what it says. if j, err := liveness.Open(filepath.Join(filepath.Dir(opts.state), liveness.FileName), - &liveness.Exec{Run: apply.ExecRunner}); j != nil { + &liveness.Exec{Run: apply.ExecRunner, System: builtFor}); j != nil { if err != nil { say(err.Error()) } @@ -1242,6 +1242,16 @@ func runLink(ctx context.Context, opts options) error { Say: say, Timeout: opts.timeout, } + // And the move of a value sealed to this machine into the vault, asked by the controller (design 57's V3, + // issue 676): the vault is asked over this queue's link, so the queue is named once it exists. + queue.MoveSecret = moveSecretFrom(opts.state, queue) + // And this machine's sealing key stated to the vault in its own name, at start and whenever it changes (ADR 0372, + // issue 786), whether or not any value was ever moved in. + go keepNodeKeyStated(ctx, func() (identity.SealingKey, error) { + return identity.LoadSealingKey(identity.SealingKeyPath(opts.state)) + }, func(verb string, args any) (json.RawMessage, error) { + return queue.AskVault(ctx, verb, args) + }, say, nodeKeyRetryFrom, nodeKeyRetryUpTo) worker := make(chan struct{}) go func() { defer close(worker) @@ -1774,8 +1784,11 @@ func healthAsReported(st liveness.Statement, ns *network.Statement) *link.Health // user's declared `root` (novox/hq ADR 0266), and reads a declaration's identities, groups and libraries // (novox/hq ADR 0328), a container's busy (ADR 0335) and a file's owner-at-runtime and the filled files (ADR // 0349 §5) and the provisioner client's account (ADR 0349 §4), which is what tells the controller it may be sent - // any of those fields. - h := &link.Health{Contract: link.ProvisionerContract, At: st.At.UTC(), Resources: []link.ResourceHealth{}} + // any of those fields; and it writes a filled file only on the client's word (HandedContract, design 57 P7), + // which is what tells it it may hand a moved module's files to the provisioner here; and it opens the vault's box + // for a core credential (VaultBoxContract, ADR 0349 §16), which is what tells it it may send that box here; and it states its sealing key to the vault (NodeKeyContract, ADR + // 0372, issue 786). + h := &link.Health{Contract: link.NodeKeyContract, At: st.At.UTC(), Resources: []link.ResourceHealth{}} for _, r := range st.Resources { h.Resources = append(h.Resources, link.ResourceHealth{Module: r.Module, Resource: r.ID, Kind: r.Kind, Target: r.Target, State: r.State, Reason: r.Reason, Since: r.Since.UTC(), Streak: r.Streak, @@ -1799,7 +1812,8 @@ func withUnits(h *link.Health, us *units.Statement) *link.Health { if us == nil || us.State == "" { return h } - h.Units = &link.UnitsHealth{State: us.State, Failed: []link.FailedUnit{}, Unread: us.Unread} + h.Units = &link.UnitsHealth{State: us.State, Failed: []link.FailedUnit{}, Unread: us.Unread, + Away: us.Away} for _, f := range us.Failed { if f.Module == "" { h.Units.Failed = append(h.Units.Failed, link.FailedUnit{Unit: f.Unit, Scope: f.Scope, Load: f.Load, @@ -2353,7 +2367,7 @@ func sealOpener(statePath string) apply.Unseal { if err != nil { return nil, err } - return key.Unseal(sealed) + return key.Open(sealed) } } diff --git a/cmd/mesh-host/movesecret.go b/cmd/mesh-host/movesecret.go new file mode 100644 index 0000000..7bbc2ec --- /dev/null +++ b/cmd/mesh-host/movesecret.go @@ -0,0 +1,131 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "time" + + "golang.org/x/crypto/nacl/box" + + "github.com/novox/mesh-host/internal/identity" + "github.com/novox/mesh-host/internal/link" +) + +// The move of a value sealed to this machine into the vault (design 57's V3, issue 676; internal/link +// movesecret.go). The controller cannot open what it holds; this machine can open what was sealed to it, and so +// hands it to the vault, sealed to a key the vault made for the call. The value is in this process for that one +// call and written nowhere. + +// vaultAsk asks one of the vault's verbs in this engine's own name and answers its result. +type vaultAsk func(verb string, args any) (json.RawMessage, error) + +// moveSecretFrom is the engine's move: this machine's sealing key, read for each ask, and the vault asked over +// the link open now. +func moveSecretFrom(statePath string, queue *link.Queue) link.MoveSecretFunc { + return func(m link.MoveSecret) (link.MovedSecret, error) { + key, err := identity.LoadSealingKey(identity.SealingKeyPath(statePath)) + if err != nil { + return link.MovedSecret{}, err + } + ask := func(verb string, args any) (json.RawMessage, error) { + return queue.AskVault(context.Background(), verb, args) + } + moved, err := moveIntoTheVault(ask, key, m) + if err != nil { + fmt.Fprintf(os.Stderr, "mesh-host: a value was not moved into the vault: %v\n", err) + } + return moved, err + } +} + +// moveIntoTheVault opens the row's value with this machine's sealing key, hands it to the vault's move-in in its two +// steps, and answers what the vault kept once its read-back — sealed to a key made here for this one call — opens +// to the same value. +func moveIntoTheVault(ask vaultAsk, key identity.SealingKey, m link.MoveSecret) (link.MovedSecret, error) { + if m.SealedTo != "" && m.SealedTo != key.Public { + return link.MovedSecret{}, errors.New("the controller's store says this value was sealed to a sealing key this " + + "machine no longer has, so it cannot be opened here") + } + value, err := key.Unseal(m.Sealed) + if err != nil { + return link.MovedSecret{}, fmt.Errorf("the value does not open with this machine's sealing key: %w", err) + } + defer wipe(value) + + // This machine's sealing key, stated in its own name: the key the vault seals the core's credentials to for the + // controller to carry here (novox/hq ADR 0349 §16, issue 763), never a key the controller names. + first := map[string]any{"origin": m.Origin, "generation": m.Generation, "sealing_key": key.Public} + if !m.MadeAt.IsZero() { + first["made_at"] = m.MadeAt.UTC().Format(time.RFC3339Nano) + } + if m.Own() { + first["node"], first["module"], first["name"] = m.Node, m.Module, m.Name + } else { + first["provider"], first["provision"] = m.Provider, m.Provision + first["consumer"], first["consumer_module"] = m.Consumer, m.ConsumerModule + if m.Local != "" { + first["local"] = m.Local + } + } + raw, err := ask("move-in", first) + if err != nil { + return link.MovedSecret{}, fmt.Errorf("the vault did not open the move: %w", err) + } + var opened struct { + Call string `json:"call"` + SealTo string `json:"seal_to"` + Secret string `json:"secret"` + } + if err := json.Unmarshal(raw, &opened); err != nil || opened.Call == "" || opened.SealTo == "" { + return link.MovedSecret{}, errors.New("the vault answered no call and no key to seal to") + } + sealed, err := identity.Seal(opened.SealTo, value) + if err != nil { + return link.MovedSecret{}, fmt.Errorf("the vault's key for the call is not a sealing key: %w", err) + } + backPub, backPriv, err := box.GenerateKey(rand.Reader) + if err != nil { + return link.MovedSecret{}, err + } + defer wipe(backPriv[:]) + raw, err = ask("move-in", map[string]any{"call": opened.Call, "sealed": sealed, + "read_back_to": base64.StdEncoding.EncodeToString(backPub[:])}) + if err != nil { + return link.MovedSecret{}, fmt.Errorf("the vault did not take %s: %w", opened.Secret, err) + } + var kept struct { + Name string `json:"name"` + Generation int `json:"generation"` + Unchanged bool `json:"unchanged"` + ReadBack string `json:"read_back"` + } + if err := json.Unmarshal(raw, &kept); err != nil || kept.Name == "" || kept.ReadBack == "" { + return link.MovedSecret{}, fmt.Errorf("the vault answered %s with no read-back", opened.Secret) + } + blob, err := base64.StdEncoding.DecodeString(kept.ReadBack) + if err != nil { + return link.MovedSecret{}, fmt.Errorf("the vault's read-back of %s is not sealed", kept.Name) + } + back, ok := box.OpenAnonymous(nil, blob, backPub, backPriv) + if !ok { + return link.MovedSecret{}, fmt.Errorf("the vault's read-back of %s does not open with the key made for it", kept.Name) + } + defer wipe(back) + if subtle.ConstantTimeCompare(back, value) != 1 { + return link.MovedSecret{}, fmt.Errorf("the vault keeps %s, but it read back another value than the one handed "+ + "in: the move of it did not hold", kept.Name) + } + return link.MovedSecret{Secret: kept.Name, Generation: kept.Generation, Unchanged: kept.Unchanged}, nil +} + +func wipe(b []byte) { + for i := range b { + b[i] = 0 + } +} diff --git a/cmd/mesh-host/movesecret_test.go b/cmd/mesh-host/movesecret_test.go new file mode 100644 index 0000000..e362c77 --- /dev/null +++ b/cmd/mesh-host/movesecret_test.go @@ -0,0 +1,134 @@ +package main + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "strings" + "testing" + + "golang.org/x/crypto/nacl/box" + + "github.com/novox/mesh-host/internal/identity" + "github.com/novox/mesh-host/internal/link" +) + +// standInVault answers move-in's two steps as the vault does: a key made for the call, the value opened with it, +// and the value kept read back sealed to the key the second step sent. lie makes its read-back another value. +type standInVault struct { + t *testing.T + pub *[32]byte + priv *[32]byte + opened map[string]any + kept string + lie string + refuse string + carried []string +} + +func (v *standInVault) ask(verb string, args any) (json.RawMessage, error) { + raw, _ := json.Marshal(args) + v.carried = append(v.carried, string(raw)) + if verb != "move-in" { + v.t.Fatalf("asked %s", verb) + } + if v.refuse != "" { + return nil, errors.New(v.refuse) + } + var a map[string]any + _ = json.Unmarshal(raw, &a) + if a["call"] == nil { + v.opened = a + pub, priv, _ := box.GenerateKey(rand.Reader) + v.pub, v.priv = pub, priv + return json.Marshal(map[string]string{"call": "c1", "seal_to": base64.StdEncoding.EncodeToString(pub[:]), + "secret": "own/anchor/postgres/superuser-password"}) + } + blob, _ := base64.StdEncoding.DecodeString(a["sealed"].(string)) + value, ok := box.OpenAnonymous(nil, blob, v.pub, v.priv) + if !ok { + v.t.Fatal("what the engine sealed does not open with the call's key") + } + v.kept = string(value) + back := v.kept + if v.lie != "" { + back = v.lie + } + key, _ := base64.StdEncoding.DecodeString(a["read_back_to"].(string)) + var to [32]byte + copy(to[:], key) + boxed, _ := box.SealAnonymous(nil, []byte(back), &to, rand.Reader) + return json.Marshal(map[string]any{"name": "own/anchor/postgres/superuser-password", "generation": 1, + "read_back": base64.StdEncoding.EncodeToString(boxed)}) +} + +const theValue = "the-superusers-password" + +func aMoveOf(t *testing.T, key identity.SealingKey) link.MoveSecret { + t.Helper() + sealed, err := identity.Seal(key.Public, []byte(theValue)) + if err != nil { + t.Fatal(err) + } + return link.MoveSecret{Node: "anchor", By: "the move", MoveSecretAsk: link.MoveSecretAsk{Module: "postgres", + Name: "superuser-password", Origin: "made", Generation: "g-1", Sealed: sealed, SealedTo: key.Public}} +} + +// The engine opens what was sealed to its machine, hands it to the vault sealed to the call's key — never in the +// clear — and answers what the vault kept only once the read-back opens to the same value. +func TestTheEngineMovesAValueSealedToItAndChecksTheReadBack(t *testing.T) { + key, err := identity.GenerateSealingKey() + if err != nil { + t.Fatal(err) + } + vault := &standInVault{t: t} + moved, err := moveIntoTheVault(vault.ask, key, aMoveOf(t, key)) + if err != nil { + t.Fatal(err) + } + if moved.Secret != "own/anchor/postgres/superuser-password" || moved.Generation != 1 || vault.kept != theValue { + t.Fatalf("moved %+v, the vault kept %q", moved, vault.kept) + } + if vault.opened["module"] != "postgres" || vault.opened["name"] != "superuser-password" || vault.opened["node"] != "anchor" || + vault.opened["origin"] != "made" || vault.opened["generation"] != "g-1" || + vault.opened["sealing_key"] != key.Public { + t.Errorf("the first step named %v", vault.opened) + } + if strings.Contains(strings.Join(vault.carried, " "), theValue) { + t.Fatal("the value crossed the bus in the clear") + } +} + +// A read-back that is not the value handed in is a failed move, said; so is a value this machine cannot open, and +// the vault's refusal. +func TestTheEngineSaysAMoveThatDidNotHold(t *testing.T) { + key, _ := identity.GenerateSealingKey() + vault := &standInVault{t: t, lie: "something-else"} + if _, err := moveIntoTheVault(vault.ask, key, aMoveOf(t, key)); err == nil || + !strings.Contains(err.Error(), "read back") { + t.Fatalf("a read-back of another value: %v", err) + } + other, _ := identity.GenerateSealingKey() + if _, err := moveIntoTheVault((&standInVault{t: t}).ask, key, aMoveOf(t, other)); err == nil || + !strings.Contains(err.Error(), "sealing key") { + t.Fatalf("a value sealed to another key: %v", err) + } + refusing := &standInVault{t: t, refuse: "the move never replaces a value the vault holds"} + if _, err := moveIntoTheVault(refusing.ask, key, aMoveOf(t, key)); err == nil || + !strings.Contains(err.Error(), "never replaces") { + t.Fatalf("the vault's refusal: %v", err) + } + // A pair credential names both its ends. + pair := aMoveOf(t, key) + pair.Module, pair.Name = "", "" + pair.Provider, pair.Provision, pair.Consumer, pair.ConsumerModule = "anchor", "database", "shanks", "gitea" + ends := &standInVault{t: t} + if _, err := moveIntoTheVault(ends.ask, key, pair); err != nil { + t.Fatal(err) + } + if ends.opened["provider"] != "anchor" || ends.opened["consumer"] != "shanks" || ends.opened["consumer_module"] != "gitea" || + ends.opened["node"] != nil { + t.Errorf("the pair's first step named %v", ends.opened) + } +} diff --git a/cmd/mesh-host/nodekey.go b/cmd/mesh-host/nodekey.go new file mode 100644 index 0000000..f00c2f8 --- /dev/null +++ b/cmd/mesh-host/nodekey.go @@ -0,0 +1,62 @@ +package main + +import ( + "context" + "fmt" + "time" + + "github.com/novox/mesh-host/internal/identity" +) + +// This machine's sealing key, stated to the vault in the engine's own name (novox/hq ADR 0372, issue 786). The vault +// records it from the machine's own account alone and, when it is a first or a changed key, says the machine's core +// secrets changed so the controller asks for them again sealed to it. Without this a machine that never moved a +// secret in (adopted after feature 57) has no key at the vault. Nothing but the public half crosses. + +const ( + // nodeKeyRetryFrom and nodeKeyRetryUpTo bound the wait between tries while the vault does not answer: it starts + // after the bus and may not serve the verb yet. + nodeKeyRetryFrom = 5 * time.Second + nodeKeyRetryUpTo = 5 * time.Minute + // nodeKeyLook is how often the key file is read to see whether it changed. + nodeKeyLook = 30 * time.Second +) + +// keepNodeKeyStated states the sealing key load answers to the vault at start, retrying with a growing wait from +// retryFrom to retryUpTo while it does not answer, and states it again whenever the key changes. It says one line +// when a key is stated and one when a try fails for another reason than the last, and returns when ctx ends. +func keepNodeKeyStated(ctx context.Context, load func() (identity.SealingKey, error), ask vaultAsk, say func(string), + retryFrom, retryUpTo time.Duration) { + stated, lastFailure := "", "" + wait := retryFrom + fail := func(why string) { + if why != lastFailure { + say("mesh-host: the sealing key was not stated to the vault, trying again: " + why) + lastFailure = why + } + wait = min(wait*2, retryUpTo) + } + look := min(nodeKeyLook, retryFrom) + for { + delay := look + key, err := load() + switch { + case err != nil: + fail(err.Error()) + delay = wait + case key.Public != stated: + if _, err := ask("node-key", map[string]string{"key": key.Public}); err != nil { + fail(err.Error()) + delay = wait + } else { + stated, lastFailure, wait = key.Public, "", retryFrom + say(fmt.Sprintf("mesh-host: this machine's sealing key %s is stated to the vault", key.Public)) + } + } + select { + case <-ctx.Done(): + return + case <-time.After(delay): + } + } +} diff --git a/cmd/mesh-host/nodekey_test.go b/cmd/mesh-host/nodekey_test.go new file mode 100644 index 0000000..80f85ce --- /dev/null +++ b/cmd/mesh-host/nodekey_test.go @@ -0,0 +1,112 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "sync" + "testing" + "time" + + "github.com/novox/mesh-host/internal/identity" + "github.com/novox/mesh-host/internal/link" +) + +// Issue 786 (novox/hq ADR 0372): the engine states its own sealing key to the vault's node-key at start, retries +// while the vault does not answer, and states it again when the key changes; the same key is not stated twice. +type keyVault struct { + mu sync.Mutex + stated []string + fail int + verbs []string +} + +func (v *keyVault) ask(verb string, args any) (json.RawMessage, error) { + v.mu.Lock() + defer v.mu.Unlock() + v.verbs = append(v.verbs, verb) + if v.fail > 0 { + v.fail-- + return nil, errors.New("the vault does not answer") + } + raw, _ := json.Marshal(args) + var a struct { + Key string `json:"key"` + } + _ = json.Unmarshal(raw, &a) + v.stated = append(v.stated, a.Key) + return json.RawMessage(`{"recorded":true}`), nil +} + +func (v *keyVault) said() []string { + v.mu.Lock() + defer v.mu.Unlock() + return append([]string(nil), v.stated...) +} + +func waitFor(t *testing.T, what string, ok func() bool) { + t.Helper() + for i := 0; i < 400; i++ { + if ok() { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("timed out: %s", what) +} + +func TestTheEngineStatesItsSealingKeyToTheVaultAtStartRetriesAndRestatesAChange(t *testing.T) { + first, _ := identity.GenerateSealingKey() + second, _ := identity.GenerateSealingKey() + var mu sync.Mutex + current := first + load := func() (identity.SealingKey, error) { + mu.Lock() + defer mu.Unlock() + return current, nil + } + vault := &keyVault{fail: 2} + var lines []string + var lmu sync.Mutex + say := func(l string) { lmu.Lock(); lines = append(lines, l); lmu.Unlock() } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go keepNodeKeyStated(ctx, load, vault.ask, say, time.Millisecond, 5*time.Millisecond) + + waitFor(t, "the first key stated after two refusals", func() bool { return len(vault.said()) == 1 }) + if vault.said()[0] != first.Public { + t.Errorf("stated %q, want the engine's own key %q", vault.said()[0], first.Public) + } + vault.mu.Lock() + verbs := append([]string(nil), vault.verbs...) + vault.mu.Unlock() + for _, v := range verbs { + if v != "node-key" { + t.Errorf("asked %s", v) + } + } + // The same key again is not stated again. + time.Sleep(60 * time.Millisecond) + if n := len(vault.said()); n != 1 { + t.Errorf("the same key was stated %d times", n) + } + mu.Lock() + current = second + mu.Unlock() + waitFor(t, "the changed key stated", func() bool { return len(vault.said()) == 2 }) + if vault.said()[1] != second.Public { + t.Errorf("stated %q after the change, want %q", vault.said()[1], second.Public) + } + lmu.Lock() + defer lmu.Unlock() + if len(lines) == 0 { + t.Error("the engine said nothing") + } +} + +// The statement a host makes names this contract (issue 786), one above the vault's box. +func TestTheNodeKeyContractIsThirteen(t *testing.T) { + if link.NodeKeyContract != 13 { + t.Errorf("NodeKeyContract %d", link.NodeKeyContract) + } +} diff --git a/cmd/mesh-host/units_test.go b/cmd/mesh-host/units_test.go index 423c286..b079b1c 100644 --- a/cmd/mesh-host/units_test.go +++ b/cmd/mesh-host/units_test.go @@ -87,10 +87,12 @@ func TestTheStatementSaysAnAccountJudgedForRoot(t *testing.T) { Groups: []string{"openrazer"}}, State: accounts.Healthy, Since: at}, }} h := withAccounts(healthAsReported(liveness.Statement{At: at}, nil), as) - // At RootContract or later: an engine that also reads the provisioner client's account says ProvisionerContract - // (novox/hq ADR 0349 §4), one above ADR 0349 §5's OwnershipContract. - if h.Contract < link.RootContract || link.RootContract != 3 || h.Contract != link.ProvisionerContract || - link.ProvisionerContract != link.OwnershipContract+1 || link.OwnershipContract != link.BusyContract+1 || + // At RootContract or later: an engine that states its sealing key to the vault says NodeKeyContract (ADR 0372, issue 786), one above + // VaultBoxContract (it opens the vault's box for the core's credentials, ADR 0349 §16, issue 755), one above HandedContract (design 57 P7), one above ProvisionerContract (novox/hq ADR + // 0349 §4), one above ADR 0349 §5's OwnershipContract. + if h.Contract < link.RootContract || link.RootContract != 3 || h.Contract != link.NodeKeyContract || + link.NodeKeyContract != link.VaultBoxContract+1 || link.VaultBoxContract != link.HandedContract+1 || + link.HandedContract != link.ProvisionerContract+1 || link.ProvisionerContract != link.OwnershipContract+1 || link.OwnershipContract != link.BusyContract+1 || link.BusyContract != link.OwnGroupContract+1 { t.Fatalf("contract %d", h.Contract) } diff --git a/cmd/mesh-host/vaultbox_nats_test.go b/cmd/mesh-host/vaultbox_nats_test.go new file mode 100644 index 0000000..74be00e --- /dev/null +++ b/cmd/mesh-host/vaultbox_nats_test.go @@ -0,0 +1,154 @@ +package main + +// Issue 755's proof on a real bus (novox/hq ADR 0349 §16): a core credential the vault sealed to this machine crosses +// the bus inside the declaration, which the controller carries and cannot open, and this engine opens it with its +// sealing key and writes the value. A listener on every subject keeps every byte that crossed; the value is never +// among them. Run against the throwaway bus merge-check.sh starts (MESH_TEST_NATS). + +import ( + "context" + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/seal" + "github.com/nats-io/nats.go" + + "github.com/novox/mesh-host/internal/apply" + "github.com/novox/mesh-host/internal/declaration" + "github.com/novox/mesh-host/internal/identity" + "github.com/novox/mesh-host/internal/link" + "github.com/novox/mesh-host/internal/store" + "github.com/novox/mesh-host/internal/system" +) + +const coreCredential = "the-provisioner-clients-bus-password-2025" + +func TestNatsTheEngineFillsACoreCredentialFromTheVaultsBoxWithNoValueInTheClear(t *testing.T) { + url := os.Getenv("MESH_TEST_NATS") + if url == "" { + t.Skip("MESH_TEST_NATS unset") + } + // This machine's sealing key, where the engine reads it. + state := filepath.Join(t.TempDir(), "state.json") + key, err := identity.GenerateSealingKey() + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(identity.SealingKeyPath(state), []byte(key.Private), 0o600); err != nil { + t.Fatal(err) + } + + // Every byte that crosses. + tap, err := nats.Connect(url) + if err != nil { + t.Fatal(err) + } + defer tap.Close() + var mu sync.Mutex + var crossed []string + if _, err := tap.Subscribe(">", func(m *nats.Msg) { + mu.Lock() + crossed = append(crossed, m.Subject+" "+string(m.Data)) + mu.Unlock() + }); err != nil { + t.Fatal(err) + } + _ = tap.Flush() + + // The engine hears its declaration on its own subject. + const node = "vaultbox" + engine, err := nats.Connect(url) + if err != nil { + t.Fatal(err) + } + defer engine.Close() + heard, err := engine.SubscribeSync(link.DeclareSubject(node)) + if err != nil { + t.Fatal(err) + } + _ = engine.Flush() + + // The vault's answer to the controller: the value in the ADR 0206 box, sealed to this machine's key. The + // controller carries it in the declaration as it is. + pub, err := key.PublicPEM() + if err != nil { + t.Fatal(err) + } + box, err := seal.Seal(coreCredential, pub) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "broker") + body, _ := json.Marshal(map[string]any{"declaration": 1, "resources": []map[string]any{ + {"id": "need-provisioner-broker", "type": "file", "path": path, "vault-sealed": box}}}) + controller, err := nats.Connect(url) + if err != nil { + t.Fatal(err) + } + defer controller.Close() + if err := controller.Publish(link.DeclareSubject(node), body); err != nil { + t.Fatal(err) + } + _ = controller.Flush() + + msg, err := heard.NextMsg(5 * time.Second) + if err != nil { + t.Fatalf("the declaration did not reach the engine: %v", err) + } + d, err := declaration.Parse(msg.Data) + if err != nil { + t.Fatal(err) + } + sys, err := system.For("arch") + if err != nil { + t.Fatal(err) + } + if _, _, err := apply.Apply(context.Background(), sys, d, store.State{}, store.OriginCarried, + func(context.Context, string, ...string) (string, error) { return "", nil }, nil, sealOpener(state)); err != nil { + t.Fatal(err) + } + on, err := os.ReadFile(path) + if err != nil || string(on) != coreCredential { + t.Fatalf("the engine wrote %q, %v", on, err) + } + + _ = tap.Flush() + time.Sleep(100 * time.Millisecond) + mu.Lock() + all := strings.Join(crossed, "\n") + mu.Unlock() + if !strings.Contains(all, link.DeclareSubject(node)) || !strings.Contains(all, "vault-sealed") { + t.Fatalf("the listener heard nothing of the declaration:\n%s", all) + } + for _, form := range []string{coreCredential, base64.StdEncoding.EncodeToString([]byte(coreCredential))} { + if strings.Contains(all, form) { + t.Fatal("the core credential crossed the bus in the clear") + } + } +} + +// The engine opens a value the controller sealed (the NaCl box) as before, and the vault's box, with one key. +func TestTheEnginesOpenerOpensBothBoxes(t *testing.T) { + state := filepath.Join(t.TempDir(), "state.json") + key, _ := identity.GenerateSealingKey() + if err := os.WriteFile(identity.SealingKeyPath(state), []byte(key.Private), 0o600); err != nil { + t.Fatal(err) + } + open := sealOpener(state) + nacl, _ := identity.Seal(key.Public, []byte("old")) + if got, err := open(nacl); err != nil || string(got) != "old" { + t.Errorf("the controller's box opened to %q, %v", got, err) + } + pub, _ := key.PublicPEM() + box, _ := seal.Seal("new", pub) + raw, _ := json.Marshal(box) + if got, err := open(string(raw)); err != nil || string(got) != "new" { + t.Errorf("the vault's box opened to %q, %v", got, err) + } +} diff --git a/examples/foundation-first-node.lock b/examples/foundation-first-node.lock index 5e05f7f..142231b 100644 --- a/examples/foundation-first-node.lock +++ b/examples/foundation-first-node.lock @@ -221,7 +221,7 @@ "path": "/var/lib/nats-module/conf/accounts.conf", "mode": "0600", "create-once": true, - "content": "// The first user list, carried by the installer because at genesis there is no mesh to\n// compose one. A bootstrap credential, rotated with the store's and replaced by the\n// controller's own composition from its first start onward.\naccounts {\n MESH {\n jetstream: enabled\n users = [\n { user: \"controller\", password: \"$2a$10$AHqJgOifIVbU41KmATiMhuXFs8xa7Wl2HuN4UVBCXdN2jIQzjqApy\", permissions: {\n publish: { allow: [\"$JS.ACK.CONTROL.controller.>\", \"$JS.ACK.EVENTS.controller.>\", \"$JS.ACK.DEAD_LETTER_NOTICES.controller.>\", \"$JS.API.>\", \"$KV.mesh-controller_asked.>\", \"$KV.mesh-controller_assignments.>\", \"$KV.mesh-controller_calls.>\", \"$KV.mesh-controller_hand-acts.>\", \"$KV.mesh-controller_conditions.>\", \"$KV.mesh-controller_condition-history.>\", \"$KV.mesh-controller_declared-config.>\", \"$KV.mesh-controller_lease.>\", \"$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>\", \"$KV.SEAT_NODE_BUILD_AGENT_cancelled.>\", \"_INBOX.enrol.>\", \"mesh.assignment.>\", \"mesh.mod.*.tool.>\", \"mesh.node.>\", \"mesh.seat.mesh-build-machine.accept.>\", \"mesh.seat.node-build-agent.accept.>\", \"mesh.seat.mesh-build-machine.tool.>\", \"mesh.seat.node-build-agent.tool.>\", \"mesh.seat.mesh-controller.event.applied\", \"mesh.seat.mesh-controller.event.built-before\", \"mesh.seat.mesh-controller.event.refused\", \"mesh.seat.mesh-controller.event.rolled-back\", \"mesh.seat.mesh-controller.event.checked\", \"mesh.seat.mesh-controller.event.plan-moved\", \"mesh.seat.mesh-controller.event.assignment-changed\", \"mesh.seat.mesh-controller.event.declared-config-changed\", \"mesh.seat.mesh-controller.event.condition-raised\", \"mesh.seat.mesh-controller.event.condition-changed\", \"mesh.seat.mesh-controller.event.condition-cleared\", \"mesh.seat.mesh-controller.event.doctor-heartbeat\", \"mesh.seat.mesh-controller.event.secret-replaced\", \"mesh.seat.mesh-controller.event.healer-acted\", \"$SRV.INFO\", \"mesh.seat.node-intrusion-prevention.tool.banned.*\", \"mesh.seat.node-backup.tool.backed-up.*\", \"mesh.seat.node-backup.tool.now.*\", \"mesh.seat.mesh-delivery.tool.stalled\", \"mesh.seat.mesh-delivery.tool.close\", \"mesh.seat.node-launcher.tool.secret.*\", \"mesh.seat.mesh-delivery.tool.release\", \"mesh.seat.mesh-delivery.tool.stop\", \"mesh.seat.mesh-delivery.tool.times\", \"mesh.seat.mesh-delivery.tool.groups\", \"mesh.seat.node-service-manager.tool.restart.*\", \"mesh.seat.mesh-controller.tool.plans\", \"mesh.events.dead.>\", \"mesh.again.>\"] }\n subscribe: { allow: [\"$JS.API.>\", \"$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>\", \"$JS.EVENT.ADVISORY.CONSUMER.DELETED.>\", \"_DELIVER.controller\", \"_DELIVER.controller.>\", \"_INBOX.controller.>\", \"mesh.control.>\", \"mesh.mod.*.event.provisioner.failing\", \"mesh.mod.*.event.provisioner.recovered\", \"mesh.mod.*.event.provisioner.retirement\", \"mesh.mod.gitea.event.pull.merged\", \"mesh.mod.gitea.event.pull.updated\", \"mesh.mod.mesh-catalog.event.catching-up\", \"mesh.mod.mesh-catalog.event.upgraded\", \"mesh.seat.mesh-build-machine.event.built\", \"mesh.seat.node-build-agent.event.built\", \"mesh.seat.operator-channel.event.decided.mesh-controller\", \"mesh.seat.mesh-controller.tool.>\", \"$SRV.PING\", \"$SRV.INFO\", \"$SRV.PING.mesh-controller\", \"$SRV.PING.mesh-controller.>\", \"$SRV.INFO.mesh-controller\", \"$SRV.INFO.mesh-controller.>\", \"$SRV.STATS\", \"$SRV.STATS.mesh-controller\", \"$SRV.STATS.mesh-controller.>\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n}\n" + "content": "// The first user list, carried by the installer because at genesis there is no mesh to\n// compose one. A bootstrap credential, rotated with the store's and replaced by the\n// controller's own composition from its first start onward.\naccounts {\n MESH {\n jetstream: enabled\n users = [\n { user: \"controller\", password: \"$2a$10$AHqJgOifIVbU41KmATiMhuXFs8xa7Wl2HuN4UVBCXdN2jIQzjqApy\", permissions: {\n publish: { allow: [\"$JS.ACK.CONTROL.controller.>\", \"$JS.ACK.EVENTS.controller.>\", \"$JS.ACK.DEAD_LETTER_NOTICES.controller.>\", \"$JS.API.>\", \"$KV.mesh-controller_asked.>\", \"$KV.mesh-controller_assignments.>\", \"$KV.mesh-controller_calls.>\", \"$KV.mesh-controller_hand-acts.>\", \"$KV.mesh-controller_conditions.>\", \"$KV.mesh-controller_condition-history.>\", \"$KV.mesh-controller_declared-config.>\", \"$KV.mesh-controller_lease.>\", \"$KV.mesh-controller_predefined.>\", \"$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>\", \"$KV.SEAT_NODE_BUILD_AGENT_cancelled.>\", \"_INBOX.enrol.>\", \"mesh.assignment.>\", \"mesh.mod.*.tool.>\", \"mesh.node.>\", \"mesh.seat.mesh-build-machine.accept.>\", \"mesh.seat.node-build-agent.accept.>\", \"mesh.seat.mesh-build-machine.tool.>\", \"mesh.seat.node-build-agent.tool.>\", \"mesh.seat.mesh-controller.event.applied\", \"mesh.seat.mesh-controller.event.built-before\", \"mesh.seat.mesh-controller.event.refused\", \"mesh.seat.mesh-controller.event.rolled-back\", \"mesh.seat.mesh-controller.event.checked\", \"mesh.seat.mesh-controller.event.plan-moved\", \"mesh.seat.mesh-controller.event.assignment-changed\", \"mesh.seat.mesh-controller.event.declared-config-changed\", \"mesh.seat.mesh-controller.event.predefined-changed\", \"mesh.seat.mesh-controller.event.condition-raised\", \"mesh.seat.mesh-controller.event.condition-changed\", \"mesh.seat.mesh-controller.event.condition-cleared\", \"mesh.seat.mesh-controller.event.doctor-heartbeat\", \"mesh.seat.mesh-controller.event.secret-replaced\", \"mesh.seat.mesh-controller.event.healer-acted\", \"$SRV.INFO\", \"mesh.seat.node-intrusion-prevention.tool.banned.*\", \"mesh.seat.node-backup.tool.backed-up.*\", \"mesh.seat.node-backup.tool.now.*\", \"mesh.seat.mesh-delivery.tool.stalled\", \"mesh.seat.mesh-delivery.tool.close\", \"mesh.seat.node-launcher.tool.secret.*\", \"mesh.seat.mesh-delivery.tool.release\", \"mesh.seat.mesh-delivery.tool.stop\", \"mesh.seat.mesh-delivery.tool.times\", \"mesh.seat.mesh-delivery.tool.groups\", \"mesh.seat.node-service-manager.tool.restart.*\", \"mesh.seat.operator-channel.tool.tell\", \"mesh.seat.mesh-controller.tool.plans\", \"mesh.events.dead.>\", \"mesh.again.>\"] }\n subscribe: { allow: [\"$JS.API.>\", \"$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>\", \"$JS.EVENT.ADVISORY.CONSUMER.DELETED.>\", \"_DELIVER.controller\", \"_DELIVER.controller.>\", \"_INBOX.controller.>\", \"mesh.control.>\", \"mesh.mod.*.event.provisioner.failing\", \"mesh.mod.*.event.provisioner.recovered\", \"mesh.mod.*.event.provisioner.retirement\", \"mesh.mod.gitea.event.pull.merged\", \"mesh.mod.gitea.event.pull.updated\", \"mesh.mod.mesh-catalog.event.catching-up\", \"mesh.mod.mesh-catalog.event.upgraded\", \"mesh.seat.mesh-build-machine.event.built\", \"mesh.seat.node-build-agent.event.built\", \"mesh.seat.operator-channel.event.decided.mesh-controller\", \"mesh.seat.mesh-controller.tool.>\", \"$SRV.PING\", \"$SRV.INFO\", \"$SRV.PING.mesh-controller\", \"$SRV.PING.mesh-controller.>\", \"$SRV.INFO.mesh-controller\", \"$SRV.INFO.mesh-controller.>\", \"$SRV.STATS\", \"$SRV.STATS.mesh-controller\", \"$SRV.STATS.mesh-controller.>\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n}\n" }, // The upstream server, until the broker step adopts it: the `nats` module's own image, built by the // mesh, takes its place there once, on these same directories (ADR 0333 §3), as the bus's planned diff --git a/go.mod b/go.mod index 627b0d9..406e5ba 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-host go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 golang.org/x/sys v0.48.0 diff --git a/go.sum b/go.sum index 315ddeb..007020d 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970 h1:9tFDQsgmI+4X7/BpZGXIr+HemPKE7YddYGqWV0lINAI= -git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010190644-622b6b4f556d h1:BR8uwv4BqyLaBwITuLylrd9DRUnu0D5DjJw0RuQSXbk= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010190644-622b6b4f556d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c h1:l5onJwoIeH8yE/PjVlEeoPyXBsHp2NQiWLllz2fwX7s= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19 h1:mAMt3KFmrHzDiBv4MLL94opWcG9y73ISMrBUyisapQE= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= diff --git a/internal/accounts/accounts.go b/internal/accounts/accounts.go index 1316527..34efd9f 100644 --- a/internal/accounts/accounts.go +++ b/internal/accounts/accounts.go @@ -119,7 +119,7 @@ func secretsOf(d *declaration.Declaration, account string) []string { var out []string for _, r := range d.Resources { f, ok := r.(*declaration.File) - if !ok || f.Path == "" || (f.Sealed == "" && len(f.Secrets) == 0) || f.Owner == account { + if !ok || f.Path == "" || (!f.Secret() && len(f.Secrets) == 0) || f.Owner == account { continue } out = append(out, f.Path) diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 9b98d0b..1c409a7 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -15,6 +15,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/hex" + "encoding/json" "errors" "fmt" "os" @@ -781,6 +782,16 @@ func ApplyMindingWindows( } } + // **A filled file is the provisioner client's to write** (novox/hq ADR 0349 §5, design 57 P7): one writer per + // file. The declaration says where it goes and how it is written, and only the write verb (WriteFilled) + // writes it, on the client's word. This apply — the controller's send and the five-minute re-apply alike — + // neither writes it nor records it, and nothing that reads it is moved for it; so only what the verb placed + // is recorded, and only that is removed as an orphan when the file leaves the declaration. + if f, isFile := resource.(*declaration.File); isFile && d.Fills(f.Path) { + report.Outcomes = append(report.Outcomes, leftToTheClient(f, &known, handedFiles, &report, log)) + continue + } + // The private network takes over the tunnel it found, ahead of the service that replaces // it (novox/hq ADR 0105): its configuration kept, its unit stopped and disabled, never // flushed. A failure here fails the service too — the mesh's interface is not started on a @@ -1250,7 +1261,9 @@ func afterWhatTheyRead(resources []declaration.Resource) []declaration.Resource const guardPrefix = declaration.AdoptionPrefix + "guard" // Unseal opens a value the mesh sealed to this node. Nil when the node has no sealing key, which -// makes every sealed file an error rather than a silently skipped one. +// makes every sealed file an error rather than a silently skipped one. A value the vault sealed (a core +// credential, novox/hq ADR 0349 §16) is given as its box's JSON, which begins with `{`; one the controller sealed +// is base64, which never does (identity.SealingKey.Open). type Unseal func(sealed string) ([]byte, error) func applyOne(ctx context.Context, sys system.System, r declaration.Resource, run Runner, @@ -1590,7 +1603,16 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal, keepF return out, fmt.Errorf( "%s is sealed to this node and this node has no sealing key", r.Path) } - opened, err := unseal(r.Sealed) + sealed := r.Sealed + if r.VaultSealed != nil { + // The vault's box, handed to the opener as its JSON (Unseal). + raw, err := json.Marshal(r.VaultSealed) + if err != nil { + return out, err + } + sealed = string(raw) + } + opened, err := unseal(sealed) if err != nil { return out, fmt.Errorf("cannot open %s: %w", r.Path, err) } diff --git a/internal/apply/filled.go b/internal/apply/filled.go index f3f4800..d95edd3 100644 --- a/internal/apply/filled.go +++ b/internal/apply/filled.go @@ -160,7 +160,7 @@ func WriteFilled( // filledAs is the declared file with the filled content in place of the declaration's. func filledAs(declared *declaration.File, content []byte) *declaration.File { f := *declared - f.Content, f.Sealed, f.Bytes, f.Secrets = string(content), "", "", nil + f.Content, f.Sealed, f.VaultSealed, f.Bytes, f.Secrets = string(content), "", nil, "", nil if f.Mode == "" && (declared.Secret() || len(declared.Secrets) > 0) { // Written as the declaration would have written it: a file carrying a credential is not world-readable. f.Mode = "0600" @@ -204,3 +204,27 @@ func recordKeeping(known *store.State, r declaration.Resource, o Outcome, was st } recordApplied(known, store.OriginDeclared, r, o, kept, keptMode, keptOwner) } + +// leftToTheClient is a filled file as an apply leaves it (novox/hq ADR 0349 §5, design 57 P7): untouched, and said +// so. A file the mesh wrote at the same path under another id, going in this apply, is handed to it as an apply hands +// a whole file (novox/hq ADR 0223): its record — what the mesh wrote there, and the original it kept — is the filled +// file's now, so the verb judges what is there against it, and the file is neither removed nor written. +func leftToTheClient(f *declaration.File, known *store.State, handedFiles map[string]store.Applied, report *Report, + log func(string)) Outcome { + if from, handed := handedFiles[f.ID]; handed { + if _, recorded := known.Find(f.ID); !recorded { + rec := from + rec.ID = f.ID + known.Record(rec) + } + delete(handedFiles, f.ID) + known.Forget(from.ID) + detail := "no longer declared; " + f.ID + " declares the file now, filled by the provisioner's client, so " + + "it was handed to it, not removed" + report.Outcomes = append(report.Outcomes, Outcome{ID: from.ID, Type: from.Type, Target: from.Target, + Action: "forgotten", Detail: detail}) + log(fmt.Sprintf(" forgotten %s (%s): %s", from.ID, from.Target, detail)) + } + return Outcome{ID: f.ID, Type: string(declaration.TypeFile), Target: f.Path, Action: "unchanged", + Detail: "filled by the provisioner's client, which alone has it written"} +} diff --git a/internal/apply/filled_apply_test.go b/internal/apply/filled_apply_test.go new file mode 100644 index 0000000..de1be82 --- /dev/null +++ b/internal/apply/filled_apply_test.go @@ -0,0 +1,218 @@ +package apply + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/novox/mesh-host/internal/declaration" + "github.com/novox/mesh-host/internal/store" +) + +// Defends novox/hq ADR 0349 §5, one writer per file (design 57 P7, issue 673): a file the declaration names as +// filled is written only through the write verb (WriteFilled), on the provisioner client's word. An apply — the +// controller's send and the five-minute re-apply alike — never writes it, never records it, and removes it as an +// orphan only when the verb placed it. + +// An apply leaves a filled file alone, whatever who owns it at runtime says: absent stays absent and is not +// recorded, and what the verb wrote stays as it wrote it through every re-apply. +func TestAnApplyNeverWritesAFilledFile(t *testing.T) { + for _, c := range []struct{ name, fields, content string }{ + {"owned", `"content":"","mode":"0640","owner-at-runtime":"owned"`, "port = 8080\n"}, + {"seed", `"content":"","mode":"0600","owner-at-runtime":"seed"`, "user default on\n"}, + {"managed block", `"into":"block","content":"","owner-at-runtime":"managed"`, "10.42.0.1 ace\n"}, + } { + t.Run(c.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "the-file") + d := filledDecl(t, path, c.fields, "") + report, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, + noServices, nil, nil) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); err == nil { + t.Fatalf("an apply wrote a filled file the client has not filled: %q", readText(t, path)) + } + if _, ok := state.Find("m.conf"); ok { + t.Errorf("an apply recorded a filled file it did not write") + } + if o := outcomeOf(report, "m.conf"); o.Action != "unchanged" || !strings.Contains(o.Detail, "provisioner") { + t.Errorf("an apply did not say it left the file to the provisioner's client: %+v", o) + } + + _, state, err = WriteFilled(context.Background(), archHost(t), d, state, path, []byte(c.content), + noServices, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + placed, _ := state.Find("m.conf") + want, wantMode := readText(t, path), modeAt(t, path) + + // The re-apply, twice over: nothing written, the verb's record kept as it wrote it. + for i := 0; i < 2; i++ { + var said []string + report, state, err = Apply(context.Background(), archHost(t), d, state, store.OriginDeclared, + noServices, func(line string) { said = append(said, line) }, nil) + if err != nil { + t.Fatal(err) + } + if got := readText(t, path); got != want || modeAt(t, path) != wantMode { + t.Fatalf("re-apply %d wrote the filled file again: %q, want %q", i+1, got, want) + } + if o := outcomeOf(report, "m.conf"); o.Action != "unchanged" { + t.Errorf("re-apply %d: %+v", i+1, o) + } + if rec, _ := state.Find("m.conf"); rec.Wrote != placed.Wrote || rec.Target != path { + t.Errorf("re-apply %d changed the verb's record: %+v, was %+v", i+1, rec, placed) + } + if len(said) != 0 { + t.Errorf("re-apply %d said something of an untouched file: %q", i+1, said) + } + } + }) + } +} + +// What reads a filled file is moved by the verb's write, once, never again by an apply: a service under +// restart-on is not restarted and a container reading it is not recreated by the re-apply. +func TestAnApplyMovesNothingForAFilledFile(t *testing.T) { + env := filepath.Join(t.TempDir(), "forge.env") + d := filledDecl(t, env, `"content":"","mode":"0600","owner-at-runtime":"owned"`, + `{"id":"forge.server","type":"container","name":"forge","image":"`+pinned+`","env-file":["`+env+`"]}`) + m := &machine{containers: map[string]*fakeContainer{}} + _, state, err := WriteFilled(context.Background(), archHost(t), d, store.State{}, env, []byte("PORT=5432\n"), + m.run, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + m.asked = nil + if _, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginDeclared, m.run, nil, + nil); err != nil { + t.Fatal(err) + } + if m.removed("forge") || m.did("docker run") { + t.Errorf("the re-apply recreated the container reading the filled file: %v", m.asked) + } + if got := readText(t, env); got != "PORT=5432\n" { + t.Errorf("the re-apply wrote the filled file: %q", got) + } + + conf := filepath.Join(t.TempDir(), "daemon.conf") + d = filledDecl(t, conf, `"content":"","owner-at-runtime":"owned"`, + `{"id":"m.daemon","type":"service","unit":"m.service","state":"running","restart-on":["m.conf"]}`) + var commands []string + _, state, err = WriteFilled(context.Background(), archHost(t), d, store.State{}, conf, []byte("level = 1\n"), + recordingServices(&commands), nil, nil, nil) + if err != nil { + t.Fatal(err) + } + commands = nil + if _, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginDeclared, + recordingServices(&commands), nil, nil); err != nil { + t.Fatal(err) + } + if n := count(commands, "systemctl stop m.service"); n != 0 { + t.Errorf("the re-apply restarted what reads the filled file: %v", commands) + } +} + +// A filled file the verb never placed is not the mesh's to remove: one found there, or put there by hand, stays +// when the file leaves the declaration. +func TestAFilledFileTheVerbNeverPlacedIsNotRemoved(t *testing.T) { + path := filepath.Join(t.TempDir(), "the-file") + if err := os.WriteFile(path, []byte("the machine's own\n"), 0o644); err != nil { + t.Fatal(err) + } + d := filledDecl(t, path, `"content":"","owner-at-runtime":"owned"`, "") + _, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, noServices, + nil, nil) + if err != nil { + t.Fatal(err) + } + gone := elsewhere(t) + report, state, err := Apply(context.Background(), archHost(t), gone, state, store.OriginDeclared, noServices, + nil, nil) + if err != nil { + t.Fatal(err) + } + if got := readText(t, path); got != "the machine's own\n" { + t.Errorf("a filled file the verb never placed was touched when it left: %q", got) + } + if o := outcomeOf(report, "m.conf"); o.Action == "removed" { + t.Errorf("a filled file the verb never placed was removed: %+v", o) + } + if _, ok := state.Find("m.conf"); ok { + t.Errorf("a record of the filled file was left") + } +} + +// What the verb placed is the mesh's: when the file leaves the declaration it is removed as an orphan, as a file +// an apply wrote is. +func TestAFilledFileTheVerbPlacedIsRemovedWhenItLeaves(t *testing.T) { + path := filepath.Join(t.TempDir(), "the-file") + d := filledDecl(t, path, `"content":"","owner-at-runtime":"owned"`, "") + _, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, noServices, + nil, nil) + if err != nil { + t.Fatal(err) + } + _, state, err = WriteFilled(context.Background(), archHost(t), d, state, path, []byte("port = 8080\n"), + noServices, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + gone := elsewhere(t) + report, state, err := Apply(context.Background(), archHost(t), gone, state, store.OriginDeclared, noServices, + nil, nil) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); err == nil { + t.Errorf("a filled file the verb placed stayed when it left the declaration: %q", readText(t, path)) + } + if o := outcomeOf(report, "m.conf"); o.Action != "removed" { + t.Errorf("not removed: %+v", o) + } + if _, ok := state.Find("m.conf"); ok { + t.Errorf("its record stayed") + } +} + +// A file the mesh wrote the old way and that is now filled keeps its record for the verb to judge against, and is +// not written over by the apply that hands it to the client; a file at the same path recorded under another id +// is handed to the filled one, not removed. +func TestAFileThatMovesToTheClientIsHandedNotRewritten(t *testing.T) { + path := filepath.Join(t.TempDir(), "the-file") + old := parseTrusted(t, fmt.Sprintf(`{"declaration":1,"resources":[{"id":"m.old","type":"file","path":%q,`+ + `"content":"port = 1\n"}]}`, path)) + _, state, err := Apply(context.Background(), archHost(t), old, store.State{}, store.OriginDeclared, noServices, + nil, nil) + if err != nil { + t.Fatal(err) + } + was, _ := state.Find("m.old") + d := filledDecl(t, path, `"content":"","owner-at-runtime":"owned"`, "") + _, state, err = Apply(context.Background(), archHost(t), d, state, store.OriginDeclared, noServices, nil, nil) + if err != nil { + t.Fatal(err) + } + if got := readText(t, path); got != "port = 1\n" { + t.Errorf("the file handed to the client was rewritten or removed: %q", got) + } + if _, ok := state.Find("m.old"); ok { + t.Errorf("the record going was kept beside the filled file's") + } + if rec, ok := state.Find("m.conf"); !ok || rec.Wrote != was.Wrote || rec.Target != path { + t.Errorf("the filled file did not take the record of what the mesh wrote there: %+v", rec) + } +} + +// elsewhere is a declaration with nothing at the filled path: one unrelated file. +func elsewhere(t *testing.T) *declaration.Declaration { + t.Helper() + return parseTrusted(t, fmt.Sprintf(`{"declaration":1,"resources":[{"id":"n.other","type":"file","path":%q,`+ + `"content":"x\n"}]}`, filepath.Join(t.TempDir(), "other"))) +} diff --git a/internal/apply/filled_test.go b/internal/apply/filled_test.go index 6166d31..2f7ff74 100644 --- a/internal/apply/filled_test.go +++ b/internal/apply/filled_test.go @@ -90,8 +90,11 @@ func TestAFilledFileIsWrittenAsItsOwnershipSays(t *testing.T) { applied := at() withContent := strings.Replace(c.fields, fmt.Sprintf(`"content":%q`, contentOf(t, c.fields)), fmt.Sprintf(`"content":%q`, c.content), 1) - want, _, err := Apply(context.Background(), archHost(t), filledDecl(t, applied, withContent, ""), - store.State{}, store.OriginDeclared, noServices, nil, nil) + // The same file declared the old way: an apply writes a filled file only on the client's word (P7). + plain := parseTrusted(t, fmt.Sprintf(`{"declaration":1,"resources":[{"id":"m.conf","type":"file",`+ + `"path":%q,%s}]}`, applied, withContent)) + want, _, err := Apply(context.Background(), archHost(t), plain, store.State{}, store.OriginDeclared, + noServices, nil, nil) if err != nil { t.Fatal(err) } diff --git a/internal/apply/vault_sealed_test.go b/internal/apply/vault_sealed_test.go new file mode 100644 index 0000000..0652c4a --- /dev/null +++ b/internal/apply/vault_sealed_test.go @@ -0,0 +1,112 @@ +package apply + +// A core credential the vault sealed to this machine (novox/hq ADR 0349 §16, issue 755): the controller carries the +// vault's box (ADR 0206) in the declaration and cannot open it; this machine opens it with its sealing key and writes +// the value, as it writes a value the controller sealed. + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/seal" + + "github.com/novox/mesh-host/internal/declaration" + "github.com/novox/mesh-host/internal/identity" + "github.com/novox/mesh-host/internal/store" +) + +// vaultSealedTo is what the vault answers the controller: the value in the ADR 0206 box, sealed to the machine's +// sealing key as SPKI PEM. +func vaultSealedTo(t *testing.T, key identity.SealingKey, value string) seal.Box { + t.Helper() + pub, err := key.PublicPEM() + if err != nil { + t.Fatal(err) + } + box, err := seal.Seal(value, pub) + if err != nil { + t.Fatal(err) + } + return box +} + +func vaultSealedFile(t *testing.T, path string, box seal.Box) *declaration.Declaration { + t.Helper() + raw := map[string]any{"declaration": 1, "resources": []map[string]any{ + {"id": "broker", "type": "file", "path": path, "vault-sealed": box}, + }} + body, _ := json.Marshal(raw) + d, err := declaration.Parse(body) + if err != nil { + t.Fatal(err) + } + return d +} + +func boxOpener(key identity.SealingKey) Unseal { + return func(sealed string) ([]byte, error) { return key.Open(sealed) } +} + +func TestAFileTheVaultSealedToThisMachineIsOpenedAndWrittenUnreadableToOthers(t *testing.T) { + key, err := identity.GenerateSealingKey() + if err != nil { + t.Fatal(err) + } + path := t.TempDir() + "/broker" + d := vaultSealedFile(t, path, vaultSealedTo(t, key, "the-provisioners-bus-password")) + report, state, err := Apply(context.Background(), archHost(t), d, store.State{}, + store.OriginCarried, noServices, nil, boxOpener(key)) + if err != nil { + t.Fatal(err) + } + on, err := os.ReadFile(path) + if err != nil || string(on) != "the-provisioners-bus-password" { + t.Fatalf("the file holds %q, %v", on, err) + } + if info, _ := os.Stat(path); info.Mode().Perm() != 0o600 { + t.Errorf("a credential landed mode %o", info.Mode().Perm()) + } + said, _ := json.Marshal(report) + kept, _ := json.Marshal(state) + for what, blob := range map[string][]byte{"the report": said, "the node's state": kept} { + if strings.Contains(string(blob), "the-provisioners-bus-password") { + t.Fatalf("%s carries the value in the clear", what) + } + } +} + +func TestAVaultBoxSealedToAnotherMachineIsRefusedAndNothingWritten(t *testing.T) { + mine, _ := identity.GenerateSealingKey() + theirs, _ := identity.GenerateSealingKey() + path := t.TempDir() + "/broker" + d := vaultSealedFile(t, path, vaultSealedTo(t, theirs, "not for you")) + if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, + store.OriginCarried, noServices, nil, boxOpener(mine)); err == nil { + t.Fatal("a box sealed to another machine was applied") + } + if _, err := os.Stat(path); err == nil { + t.Fatal("something was written before the failure") + } +} + +func TestAVaultSealedFileSaysWhatIsInItOnce(t *testing.T) { + key, _ := identity.GenerateSealingKey() + box, _ := json.Marshal(vaultSealedTo(t, key, "x")) + for what, extra := range map[string]string{ + "content": `"content":"a"`, "sealed": `"sealed":"b"`, "bytes": `"bytes":"Yw=="`, + "secrets": `"content":"${secret:x}","secrets":{"x":"c"}`, "into": `"into":"json","content":"{}"`, + } { + _, err := declaration.Parse([]byte(`{"declaration":1,"resources":[{"id":"f","type":"file","path":"/etc/x",` + + `"vault-sealed":` + string(box) + `,` + extra + `}]}`)) + if err == nil { + t.Errorf("a vault-sealed file that also says %s was accepted", what) + } + } + if _, err := declaration.Parse([]byte(`{"declaration":1,"resources":[{"id":"f","type":"file","path":"/etc/x",` + + `"vault-sealed":{"v":2,"eph":"a","iv":"b","tag":"c","ct":"d"}}]}`)); err == nil { + t.Error("a box of a version the SDK does not open was accepted") + } +} diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index 6ef97ec..c66e9e7 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -19,6 +19,8 @@ import ( "sort" "strconv" "strings" + + "git.novox.be/novox/mesh-sdk/go/seal" ) // Version is the vocabulary this host speaks. A declaration naming any other version is @@ -205,6 +207,12 @@ type File struct { // by looking rather than by knowing which field won. Sealed string `json:"sealed,omitempty"` + // VaultSealed is content the vault sealed to this node's sealing key, in the mesh's sealed box (novox/hq ADR + // 0206): a core module's own credential, which the controller carries in the declaration and cannot open + // (ADR 0349 §16, issue 755). The same promise as Sealed, from the vault rather than the controller; sent only to + // an engine that states VaultBoxContract. Exclusive with Content, Sealed and Bytes. + VaultSealed *seal.Box `json:"vault-sealed,omitempty"` + // Trusted is the catalogue's word on whether the settings this file's content took are trusted (novox/hq // issue 339). It is the controller's to act on, and a controller takes it out before it sends a declaration. // Accepted here, and nothing is done with it, so that a controller that passes it on — an older one, or one @@ -278,7 +286,7 @@ func (f *File) Seeded() bool { return f.CreateOnce || f.OwnerAtRuntime == Runtim // Secret reports whether this file arrived sealed, which is what decides both that it must be // opened before writing and that its contents must never appear in a report. -func (f *File) Secret() bool { return f.Sealed != "" } +func (f *File) Secret() bool { return f.Sealed != "" || f.VaultSealed != nil } func (f *File) Identity() string { return f.ID } func (f *File) Kind() Type { return TypeFile } @@ -351,7 +359,7 @@ func (f *File) validate(where string, _ bool) []string { where, f.At)) } if f.Into == IntoJSON || f.Into == IntoBlock { - if f.Sealed != "" || f.Bytes != "" || len(f.Secrets) > 0 || f.CreateOnce { + if f.Secret() || f.Bytes != "" || len(f.Secrets) > 0 || f.CreateOnce { problems = append(problems, where+ ": a file written into says only its part, in content — not sealed, bytes, "+ "secrets or create-once") @@ -366,6 +374,16 @@ func (f *File) validate(where string, _ bool) []string { said = append(said, name) } } + if f.VaultSealed != nil { + said = append(said, "vault-sealed") + if f.VaultSealed.V != seal.Version { + problems = append(problems, fmt.Sprintf("%s: vault-sealed is a sealed box of version %d, and this "+ + "node-engine opens version %d", where, f.VaultSealed.V, seal.Version)) + } + if len(f.Secrets) > 0 { + problems = append(problems, where+": a file the vault sealed whole has no holes for secrets") + } + } if len(said) > 1 { sort.Strings(said) problems = append(problems, where+ diff --git a/internal/identity/sealing.go b/internal/identity/sealing.go index 218f8bc..15794c1 100644 --- a/internal/identity/sealing.go +++ b/internal/identity/sealing.go @@ -3,11 +3,15 @@ package identity import ( "crypto/ecdh" "crypto/rand" + "crypto/x509" "encoding/base64" + "encoding/json" + "encoding/pem" "fmt" "os" "strings" + "git.novox.be/novox/mesh-sdk/go/seal" "golang.org/x/crypto/nacl/box" ) @@ -141,3 +145,63 @@ func Seal(publicKey string, value []byte) (string, error) { } return base64.StdEncoding.EncodeToString(sealed), nil } + +// **The vault's box, opened with the same key** (novox/hq ADR 0349 §16, ADR 0206, issue 755). The core's own +// credentials travel in the declaration as values the vault sealed to this machine, which the controller carries and +// cannot open. The vault seals in the mesh's sealed box (mesh-sdk go/seal), whose keys travel as PEM: the public half +// as SPKI, the private as PKCS #8. They are this key's two halves in another wrapping, never another key. + +// PublicPEM is this machine's sealing key's public half as SPKI PEM, the form the vault seals to. +func (s SealingKey) PublicPEM() (string, error) { + raw, err := base64.StdEncoding.DecodeString(s.Public) + if err != nil || len(raw) != 32 { + return "", fmt.Errorf("this node's sealing key is unusable") + } + pub, err := ecdh.X25519().NewPublicKey(raw) + if err != nil { + return "", fmt.Errorf("this node's sealing key is unusable: %w", err) + } + der, err := x509.MarshalPKIXPublicKey(pub) + if err != nil { + return "", err + } + return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})), nil +} + +// OpenVaultBox opens a value the vault sealed to this machine. It fails for a box sealed to another key, or changed. +func (s SealingKey) OpenVaultBox(b seal.Box) ([]byte, error) { + raw, err := base64.StdEncoding.DecodeString(s.Private) + if err != nil || len(raw) != 32 { + return nil, fmt.Errorf("this node's sealing key is unusable") + } + priv, err := ecdh.X25519().NewPrivateKey(raw) + if err != nil { + return nil, fmt.Errorf("this node's sealing key is unusable: %w", err) + } + der, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return nil, err + } + block := &pem.Block{Type: "PRIVATE KEY", Bytes: der} + opened, err := seal.Open(b, string(pem.EncodeToMemory(block))) + for i := range block.Bytes { + block.Bytes[i] = 0 + } + if err != nil { + return nil, fmt.Errorf("this was not sealed by the vault to this node's current key") + } + return []byte(opened), nil +} + +// Open opens anything the mesh sealed to this machine: the vault's box, given as its JSON (it begins with `{`), or +// the controller's sealed value, base64 (which never does). +func (s SealingKey) Open(sealed string) ([]byte, error) { + if strings.HasPrefix(strings.TrimSpace(sealed), "{") { + var b seal.Box + if err := json.Unmarshal([]byte(sealed), &b); err != nil { + return nil, fmt.Errorf("this is not the vault's sealed box: %w", err) + } + return s.OpenVaultBox(b) + } + return s.Unseal(sealed) +} diff --git a/internal/link/handover.go b/internal/link/handover.go index 12f5d14..1c5188b 100644 --- a/internal/link/handover.go +++ b/internal/link/handover.go @@ -143,7 +143,14 @@ func believed(check HandOverCheck, context string, withPath bool, body []byte) ( // believedBytes is believed with the ask's bytes as signed, for an ask that carries more than a hand-over's // fields: read out of them only once they are believed. func believedBytes(check HandOverCheck, context string, withPath bool, body []byte) (HandOverAsk, []byte, string) { - if len(body) > MaxHandOverAsk { + return believedBytesWithin(check, context, withPath, body, MaxHandOverAsk) +} + +// believedBytesWithin is believedBytes for an ask bounded at max bytes: a move carries a sealed value, more than +// a hand-over's few hundred bytes (movesecret.go). +func believedBytesWithin(check HandOverCheck, context string, withPath bool, body []byte, max int) (HandOverAsk, + []byte, string) { + if len(body) > max { return HandOverAsk{}, nil, fmt.Sprintf("the ask is %d bytes, more than an ask", len(body)) } var signed SignedHandOver diff --git a/internal/link/hearing_nats.go b/internal/link/hearing_nats.go index a9aace5..d8b945f 100644 --- a/internal/link/hearing_nats.go +++ b/internal/link/hearing_nats.go @@ -68,6 +68,8 @@ type natsLink struct { judging *nats.Subscription // acting hears the controller asking for the take act on a library (novox/hq ADR 0328 §7). acting *nats.Subscription + // moving hears the controller asking to move a value sealed to this machine into the vault (issue 676). + moving *nats.Subscription node string // signer is the mesh's key, which a hand-over ask is verified with as a declaration is (issue 356). signer ed25519.PublicKey @@ -279,6 +281,24 @@ func (l *natsLink) HearAccessActs(handle AccessActFunc) { } } +// HearMoveSecrets listens for the controller asking this node to move one value sealed to it into the vault (design +// 57's V3, issue 676), on this machine's own subject, verified as a hand-over is (AnswerMoveSecret). A bus whose +// user list is older than the grant refuses the subscription; the controller then hears no answer. +func (l *natsLink) HearMoveSecrets(handle MoveSecretFunc) { + if handle == nil { + return + } + check := HandOverCheck{Signer: l.signer, Node: l.node} + moving, err := l.conn.Subscribe(AskMoveSecretSubject(l.node), func(msg *nats.Msg) { + if body, answer := MoveSecretReply(check, handle, msg.Reply, msg.Data); answer { + _ = msg.Respond(body) + } + }) + if err == nil { + l.moving = moving + } +} + func (l *natsLink) Declarations() <-chan Declaration { return l.arrived } func (l *natsLink) AskedToReport() <-chan struct{} { return l.asked } func (l *natsLink) Lost() <-chan error { return l.lost } @@ -299,6 +319,9 @@ func (l *natsLink) Close() { if l.acting != nil { _ = l.acting.Unsubscribe() } + if l.moving != nil { + _ = l.moving.Unsubscribe() + } if l.judging != nil { _ = l.judging.Unsubscribe() } diff --git a/internal/link/messages.go b/internal/link/messages.go index 3a849a1..319fbd7 100644 --- a/internal/link/messages.go +++ b/internal/link/messages.go @@ -308,6 +308,26 @@ const OwnershipContract = 9 // declaration for it. const ProvisionerContract = 10 +// HandedContract is the statement of a host that writes a filled file only on the provisioner client's word (novox/hq +// ADR 0349 §5, design 57 P7): an apply neither writes nor records a file the declaration names as filled, and removes +// one as an orphan only when the write verb placed it. Its presence is what tells the controller it may hand a moved +// module's files to the provisioner here (mesh-controller internal/link/protocol.go's HandedContract): to an older +// host a filled file would have two writers. +const HandedContract = 11 + +// VaultBoxContract is the statement of a host that also reads a file's `vault-sealed` (novox/hq ADR 0349 §16, issue +// 755): a core module's own credential the vault sealed to this machine in the mesh's sealed box (ADR 0206), which +// the controller carries and cannot open, opened here with this machine's sealing key and written as a sealed file +// is. Its presence is what tells the controller it may send the vault's box here (mesh-controller internal/link +// protocol.go's VaultBoxContract); an older host refuses the whole declaration for the field, and is sent the +// controller's sealed value as before. +const VaultBoxContract = 12 + +// NodeKeyContract is the statement of a host that states its own sealing key to the vault's node-key, in its own name, +// at its start and whenever the key changes (novox/hq ADR 0372, issue 786). The same number as mesh-controller +// internal/link/protocol.go's NodeKeyContract. +const NodeKeyContract = 13 + // Health is one statement of every long-running resource's state on this machine (to-be 48 §4). Said in // every report, as an event on each change, and again every minute while anything is not healthy — so a // lost statement is not a lost fault. @@ -372,6 +392,8 @@ type UnitsHealth struct { Failed []FailedUnit `json:"failed"` // Unread names a manager that could not be read, with why. Unread []string `json:"unread,omitempty"` + // Away names an account's own manager not running, so not read: nothing in it is failed (issue 764). + Away []string `json:"away,omitempty"` } // FailedUnit is one failed unit no module places. diff --git a/internal/link/movesecret.go b/internal/link/movesecret.go new file mode 100644 index 0000000..fdfee75 --- /dev/null +++ b/internal/link/movesecret.go @@ -0,0 +1,209 @@ +package link + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" +) + +// The move of a secret into the vault, asked over the bus (design 57's V3, issue 676; the operator's decision of +// 2026-10-14 on that issue). +// +// **Only the machine a value was sealed to can open it**, so the controller, which cannot, asks each machine's +// engine to move what was sealed to it: one row of its store at a time, on AskMoveSecretSubject, signed as a +// hand-over is and held to the same checks (believed), under a signing context of its own, naming the row — a +// module's own secret on this machine, or a pair credential with this machine at one end — its origin, the +// controller's generation, when it was made, and the value as the store holds it, sealed to this machine. The +// engine opens it with its sealing key, hands it to the vault's `move-in` in the vault's two steps, and answers +// what the vault kept only once the vault's read-back opens to the same value. Nothing is rotated; the engine +// writes nothing on this machine. The controller holds the same shapes in its own link code, with a test on each +// side holding the field names. + +// AskMoveSecretSubject is where the controller asks this node's engine to move one value sealed to it into the vault. +func AskMoveSecretSubject(node string) string { return "mesh.node." + node + ".ask.move-secret" } + +// MoveSecretContext is prefixed to a move ask's bytes before signing: never another ask's, nor a declaration's. +const MoveSecretContext = "novox-mesh move-secret v1\n" + +// MoveSecretAsk is a move's own fields, beside a hand-over's (node, by, expires, nonce; no path): the row — module +// and name for a module's own secret on the ask's node; provider, provision, consumer, consumer module and local +// name for a pair credential — where its value came from, the controller's generation, when it was made, the value +// sealed to this machine as the store holds it, and the sealing key the store says it was sealed to. +type MoveSecretAsk struct { + Module string `json:"module,omitempty"` + Name string `json:"name,omitempty"` + Provider string `json:"provider,omitempty"` + Provision string `json:"provision,omitempty"` + Consumer string `json:"consumer,omitempty"` + ConsumerModule string `json:"consumer_module,omitempty"` + Local string `json:"local,omitempty"` + Origin string `json:"origin"` + Generation string `json:"generation,omitempty"` + MadeAt time.Time `json:"made_at"` + Sealed string `json:"sealed"` + SealedTo string `json:"sealed_to,omitempty"` +} + +// MoveSecretAnswer is the engine's answer: what the vault kept, or why nothing was moved — never both. +type MoveSecretAnswer struct { + Said string `json:"said,omitempty"` + Refused string `json:"refused,omitempty"` + Secret string `json:"secret,omitempty"` + Generation int `json:"generation,omitempty"` + Unchanged bool `json:"unchanged,omitempty"` +} + +// MoveSecret is one move asked and believed, as the handler is given it: the row, this machine, and who asked. +type MoveSecret struct { + MoveSecretAsk + Node, By string +} + +// Own says the row is a module's own secret rather than a pair credential. +func (m MoveSecret) Own() bool { return m.Module != "" || m.Name != "" } + +// MovedSecret is what the vault kept: its name, its generation, and whether it held the value already. +type MovedSecret struct { + Secret string + Generation int + Unchanged bool +} + +// MoveSecretFunc moves one value into the vault: what it kept, or why not. +type MoveSecretFunc func(MoveSecret) (MovedSecret, error) + +// MaxMoveSecretAsk bounds a move's request: a row's names, a time, a value sealed and a signature. +const MaxMoveSecretAsk = 64 << 10 + +// movedOrigins are the origins the controller's store holds. +var movedOrigins = map[string]bool{"made": true, "accepted": true, "issued": true} + +func refusedMove(why string) MoveSecretAnswer { + return MoveSecretAnswer{Refused: why + "; nothing was moved"} +} + +// oneName is a short name of one line. +func oneName(s string) bool { + return s != "" && len(s) <= MaxActField && !strings.ContainsAny(s, "\r\n\x00/ ") +} + +// AnswerMoveSecret is the engine's judgement of one move ask's bytes: believed as a hand-over is, then the row — +// one shape, this machine's, a value to open and an origin — and only then the handler. +func AnswerMoveSecret(check HandOverCheck, handle MoveSecretFunc, body []byte) MoveSecretAnswer { + if handle == nil { + return refusedMove("this engine moves no secret into the vault") + } + ask, signed, refused := believedBytesWithin(check, MoveSecretContext, false, body, MaxMoveSecretAsk) + if refused != "" { + return refusedMove(refused) + } + var move MoveSecretAsk + if err := json.Unmarshal(signed, &move); err != nil { + return refusedMove("the signed ask is not a move's ask: " + err.Error()) + } + own := move.Module != "" || move.Name != "" + pair := move.Provider != "" || move.Provision != "" || move.Consumer != "" || move.ConsumerModule != "" || + move.Local != "" + switch { + case own == pair: + return refusedMove("the ask names neither one module's own secret nor one pair credential") + case own && (!oneName(move.Module) || !oneName(move.Name)): + return refusedMove("the ask's module and name are not short names") + case pair && (!oneName(move.Provider) || !oneName(move.Provision) || !oneName(move.Consumer) || + !oneName(move.ConsumerModule) || (move.Local != "" && !oneName(move.Local))): + return refusedMove("the ask's provider, provision, consumer, consuming module and local name are not short names") + case pair && move.Provider != ask.Node && move.Consumer != ask.Node: + return refusedMove(fmt.Sprintf("the pair is between %s and %s, and this is %s", move.Provider, move.Consumer, + ask.Node)) + case !movedOrigins[move.Origin]: + return refusedMove(fmt.Sprintf("the origin %q is not made, accepted or issued", move.Origin)) + case strings.TrimSpace(move.Sealed) == "": + return refusedMove("the ask carries no value sealed to this machine") + case len(move.Generation) > MaxActField || strings.ContainsAny(move.Generation, "\r\n\x00"): + return refusedMove("the ask's generation is not one short line") + } + moved, err := handle(MoveSecret{MoveSecretAsk: move, Node: ask.Node, By: ask.By}) + if err != nil { + return refusedMove(err.Error()) + } + said := fmt.Sprintf("%s is in the vault at generation %d, read back the same", moved.Secret, moved.Generation) + if moved.Unchanged { + said = fmt.Sprintf("the vault held %s already, the same value (generation %d)", moved.Secret, moved.Generation) + } + return MoveSecretAnswer{Said: said, Secret: moved.Secret, Generation: moved.Generation, Unchanged: moved.Unchanged} +} + +// MoveSecretReply is what a move request is answered with, and whether to answer, as HandOverReply. +func MoveSecretReply(check HandOverCheck, handle MoveSecretFunc, reply string, body []byte) ([]byte, bool) { + if reply == "" { + return nil, false + } + answer, err := json.Marshal(AnswerMoveSecret(check, handle, body)) + if err != nil { + return nil, false + } + return answer, true +} + +// MoveSecrets is a link that hears the controller asking this node to move a value into the vault. Optional. +type MoveSecrets interface { + HearMoveSecrets(MoveSecretFunc) +} + +// ---- asking the vault ------------------------------------------------------------------------------------------ + +// VaultSeat is the vault's seat; its verbs are asked on `mesh.seat..call..`, the subject that +// names its caller (novox/hq issue 365), which the bus lets only that user publish. +const VaultSeat = "mesh-vault" + +// NodeEngineUser is the bus user of a machine's node-engine, as the controller's broker names it. +func NodeEngineUser(node string) string { return "node." + node } + +// CalledVaultSubject is where this node's engine asks one of the vault's verbs in its own name: each dot of its +// user's name written `~`, as the controller's broker writes a caller's token. +func CalledVaultSubject(verb, node string) string { + return "mesh.seat." + VaultSeat + ".call." + verb + "." + strings.ReplaceAll(NodeEngineUser(node), ".", "~") +} + +// VaultWithin is how long the engine gives the vault to answer one step. +const VaultWithin = 15 * time.Second + +// AskVault asks one of the vault's verbs in this node's engine's own name over the link open now, and answers its +// result, or its refusal as an error. +func (q *Queue) AskVault(ctx context.Context, verb string, args any) (json.RawMessage, error) { + q.init() + q.mu.Lock() + bus := q.bus + q.mu.Unlock() + if bus == nil { + return nil, fmt.Errorf("%w: this machine has no link to the bus open now", ErrNotAsked) + } + asker, ok := bus.(ToolBus) + if !ok { + return nil, fmt.Errorf("%w: the link to the bus open now cannot ask the vault", ErrNotAsked) + } + body, err := json.Marshal(args) + if err != nil { + return nil, err + } + asking, cancel := context.WithTimeout(ctx, VaultWithin) + defer cancel() + reply, err := asker.Ask(asking, CalledVaultSubject(verb, q.Membership.Node), body) + if err != nil { + return nil, fmt.Errorf("the vault's %s could not be asked: %w", verb, err) + } + var env struct { + Result json.RawMessage `json:"result"` + Error string `json:"error"` + } + if err := json.Unmarshal(reply, &env); err != nil { + return nil, fmt.Errorf("the vault answered %s with something that is not a tool's answer", verb) + } + if env.Error != "" { + return nil, errors.New(env.Error) + } + return env.Result, nil +} diff --git a/internal/link/movesecret_test.go b/internal/link/movesecret_test.go new file mode 100644 index 0000000..5cb244a --- /dev/null +++ b/internal/link/movesecret_test.go @@ -0,0 +1,141 @@ +package link + +import ( + "crypto/ed25519" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/nats-io/nats.go" +) + +// The move's ask (design 57's V3, issue 676): a hand-over's fields and the row's own in one signed object, under a +// signing context of its own. The controller's side holds the same names and words. +func TestTheMovesAskKeepsItsFieldNamesAndContext(t *testing.T) { + body, _ := json.Marshal(MoveSecretAsk{Module: "m", Name: "n", Provider: "p", Provision: "q", Consumer: "c", + ConsumerModule: "cm", Local: "l", Origin: "made", Generation: "g", MadeAt: time.Unix(1, 0).UTC(), + Sealed: "s", SealedTo: "k"}) + if got := keysOf(t, body); got != "consumer consumer_module generation local made_at module name origin provider "+ + "provision sealed sealed_to" { + t.Fatalf("the move's fields are %q", got) + } + if AskMoveSecretSubject("ace") != "mesh.node.ace.ask.move-secret" { + t.Fatalf("the subject is %q", AskMoveSecretSubject("ace")) + } + if MoveSecretContext != "novox-mesh move-secret v1\n" { + t.Fatalf("the signing context is %q", MoveSecretContext) + } + answer, _ := json.Marshal(MoveSecretAnswer{Said: "s", Refused: "r", Secret: "own/a/b/c", Generation: 1, Unchanged: true}) + if got := keysOf(t, answer); got != "generation refused said secret unchanged" { + t.Fatalf("the answer's fields are %q", got) + } +} + +// signedMove is a move's ask as the controller makes it. +func signedMove(t *testing.T, key ed25519.PrivateKey, ask HandOverAsk, move MoveSecretAsk) []byte { + t.Helper() + raw, _ := json.Marshal(struct { + HandOverAsk + MoveSecretAsk + }{ask, move}) + body, _ := json.Marshal(SignedHandOver{Ask: raw, Signature: ed25519.Sign(key, append([]byte(MoveSecretContext), raw...))}) + return body +} + +// A move is believed as every signed ask is, then names one row this machine holds — its own secret, or a pair +// credential with this machine at one end — with a value sealed to it and an origin the store has; only then is the +// handler given it. A hand-over's signature is not a move's. +func TestAMoveIsTakenOnlyForARowThisMachineHolds(t *testing.T) { + m, key := aMember(t) + now := time.Now() + var given []MoveSecret + handle := func(s MoveSecret) (MovedSecret, error) { + given = append(given, s) + return MovedSecret{Secret: "own/" + m.Node + "/postgres/superuser-password", Generation: 1}, nil + } + check := HandOverCheck{Signer: m.Signer, Node: m.Node, Nonces: &Nonces{}} + ask := func(nonce string) HandOverAsk { + return HandOverAsk{Node: m.Node, By: "the move", Expires: now.Add(HandOverGood), Nonce: nonce} + } + own := MoveSecretAsk{Module: "postgres", Name: "superuser-password", Origin: "made", Sealed: "c2VhbGVk"} + for why, body := range map[string][]byte{ + "a hand-over's": signedAsk(t, key, ask(aNonce+"01")), + "no value": signedMove(t, key, ask(aNonce+"02"), MoveSecretAsk{Module: "postgres", Name: "x", Origin: "made"}), + "no origin": signedMove(t, key, ask(aNonce+"03"), MoveSecretAsk{Module: "postgres", Name: "x", Sealed: "c2Vh"}), + "both shapes": signedMove(t, key, ask(aNonce+"04"), MoveSecretAsk{Module: "postgres", Name: "x", Provider: m.Node, + Provision: "database", Consumer: "shanks", ConsumerModule: "gitea", Origin: "made", Sealed: "c2Vh"}), + "neither end": signedMove(t, key, ask(aNonce+"05"), MoveSecretAsk{Provider: "anchor", Provision: "database", + Consumer: "shanks", ConsumerModule: "gitea", Origin: "made", Sealed: "c2Vh"}), + "a path": signedMove(t, key, HandOverAsk{Node: m.Node, Path: "/etc", By: "x", Expires: now.Add(HandOverGood), + Nonce: aNonce + "06"}, own), + "two lines": signedMove(t, key, ask(aNonce+"07"), MoveSecretAsk{Module: "postgres", Name: "x\ny", Origin: "made", + Sealed: "c2Vh"}), + } { + if a := AnswerMoveSecret(check, handle, body); a.Refused == "" || !strings.HasSuffix(a.Refused, "nothing was moved") { + t.Errorf("%s: not refused, or refused without saying nothing was moved: %+v", why, a) + } + } + if len(given) != 0 { + t.Fatalf("a refused ask reached the move: %+v", given) + } + a := AnswerMoveSecret(check, handle, signedMove(t, key, ask(aNonce+"08"), own)) + if a.Refused != "" || a.Secret != "own/"+m.Node+"/postgres/superuser-password" || a.Generation != 1 || a.Said == "" || + len(given) != 1 || given[0].Name != "superuser-password" || given[0].By != "the move" { + t.Fatalf("a good ask: %+v %+v", a, given) + } + pair := MoveSecretAsk{Provider: "anchor", Provision: "database", Consumer: m.Node, ConsumerModule: "gitea", + Origin: "accepted", Sealed: "c2Vh"} + if a := AnswerMoveSecret(check, handle, signedMove(t, key, ask(aNonce+"09"), pair)); a.Refused != "" { + t.Fatalf("a pair this machine consumes was refused: %+v", a) + } + // What the move refuses is said, and nothing else. + refusing := func(MoveSecret) (MovedSecret, error) { return MovedSecret{}, errors.New("the vault said no") } + if a := AnswerMoveSecret(check, refusing, signedMove(t, key, ask(aNonce+"10"), own)); !strings.Contains(a.Refused, + "the vault said no") || a.Secret != "" { + t.Fatalf("a refused move: %+v", a) + } +} + +// **Against a real server**: the move's signed ask on the node's own subject is answered with what the handler +// moved, and the engine asks the vault on the subject that names it, `node~` last. +func TestNatsAMoveIsAskedOfTheNodeAndTheVaultIsAskedInItsName(t *testing.T) { + conn, js := aBus(t) + m, key := aMember(t) + vault, err := conn.Subscribe("mesh.seat.mesh-vault.call.move-in.node~"+m.Node, func(msg *nats.Msg) { + _ = msg.Respond([]byte(`{"result":{"call":"c1","seal_to":"k"}}`)) + }) + if err != nil { + t.Fatal(err) + } + defer vault.Unsubscribe() + l := &natsLink{conn: conn, js: js, node: m.Node, signer: m.Signer} + q := &Queue{Membership: m} + q.attach(t.Context(), l) + l.HearMoveSecrets(func(s MoveSecret) (MovedSecret, error) { + raw, err := q.AskVault(t.Context(), "move-in", map[string]string{"module": s.Module}) + if err != nil || !strings.Contains(string(raw), `"call":"c1"`) { + return MovedSecret{}, fmt.Errorf("the vault answered %s, %v", raw, err) + } + return MovedSecret{Secret: "own/" + s.Node + "/" + s.Module + "/" + s.Name, Generation: 1}, nil + }) + defer l.Close() + if err := conn.Flush(); err != nil { + t.Fatal(err) + } + ask := HandOverAsk{Node: m.Node, By: "the move", Expires: time.Now().Add(HandOverGood), Nonce: strings.Repeat("ab", 16)} + reply, err := conn.Request(AskMoveSecretSubject(m.Node), signedMove(t, key, ask, + MoveSecretAsk{Module: "postgres", Name: "superuser-password", Origin: "made", Sealed: "c2Vh"}), 5*time.Second) + if err != nil { + t.Fatal(err) + } + var said MoveSecretAnswer + if err := json.Unmarshal(reply.Data, &said); err != nil || said.Secret != "own/"+m.Node+"/postgres/superuser-password" { + t.Fatalf("answered %s, %v", reply.Data, err) + } + if _, err := (&Queue{Membership: Membership{Node: "nowhere"}}).AskVault(t.Context(), "move-in", nil); !errors.Is(err, ErrNotAsked) { + t.Fatalf("a queue with no link asked the vault: %v", err) + } +} diff --git a/internal/link/queue.go b/internal/link/queue.go index 1c345ee..9d064a6 100644 --- a/internal/link/queue.go +++ b/internal/link/queue.go @@ -65,8 +65,11 @@ type Queue struct { // AccessAct judges and runs the take act on an access's folder, asked by the controller on an approved // warrant (novox/hq ADR 0328 §7). Nil answers every such ask with a refusal. AccessAct AccessActFunc - Say Announce - Timeout time.Duration + // MoveSecret moves one value sealed to this machine into the vault, asked by the controller during the move of + // the secrets (design 57's V3, issue 676). Nil answers every such ask with a refusal. + MoveSecret MoveSecretFunc + Say Announce + Timeout time.Duration once sync.Once wake chan struct{} diff --git a/internal/link/run.go b/internal/link/run.go index eb46c48..72028b9 100644 --- a/internal/link/run.go +++ b/internal/link/run.go @@ -256,6 +256,10 @@ func serve(ctx context.Context, link Link, m Membership, queue *Queue, say Annou if s, ok := link.(AccessActs); ok { s.HearAccessActs(queue.AccessAct) } + // And asking to move a value sealed to this machine into the vault (design 57's V3, issue 676). + if s, ok := link.(MoveSecrets); ok { + s.HearMoveSecrets(queue.MoveSecret) + } for { // Asked to stop — by standing aside, say — nothing further is taken in. diff --git a/internal/liveness/liveness_test.go b/internal/liveness/liveness_test.go index b304706..aa39729 100644 --- a/internal/liveness/liveness_test.go +++ b/internal/liveness/liveness_test.go @@ -330,3 +330,56 @@ func TestOneLookIsOneReadOfEveryContainer(t *testing.T) { t.Fatalf("a container the inspect found, beside one it did not: %+v", s) } } + +// **A look starts no account's manager** (novox/hq issue 764, ADR 0177): a user-scoped unit's manager is +// asked of the machine's first; one not running is not read, nothing is started, and its unit is said +// unknown with why — away, never down; one running is read as before. +func TestAUserUnitsManagerNotRunningIsNotReadAndNotDown(t *testing.T) { + var asked []string + manager := "inactive" + run := func(_ context.Context, name string, args ...string) (string, error) { + asked = append(asked, name+" "+strings.Join(args, " ")) + switch { + case name == "getent": + return "operator:x:1000:1000::/home/operator:/bin/zsh\n", nil + case name == "systemctl" && len(args) > 0 && args[0] == "is-active": + if manager != "active" { + return manager + "\n", errors.New("systemctl exited 3") + } + return "active\n", nil + case name == "systemctl": + return "Id=dunst.service\nLoadState=loaded\nActiveState=active\nSubState=running\nNRestarts=0\nInvocationID=i1\n", nil + } + return "", errors.New("not a command the judge may run") + } + c := &clock{now: t0} + j := aJudge(t, &Exec{Run: run, System: "arch"}, c, "") + j.Set([]Resource{{Module: "dunst", ID: "dunst.daemon", Kind: KindService, Target: "dunst.service", + Scope: "user", User: "operator"}}) + var st Statement + for i := 0; i < 4; i++ { + c.now = t0.Add(time.Duration(i) * time.Minute) + st, _ = j.Look(t.Context()) + } + if s := stateOf(t, st, "dunst.daemon"); s.State != Unknown || !strings.Contains(s.Reason, "not logged in") { + t.Fatalf("a unit in a manager not running is away, not down: %+v", s) + } + for _, a := range asked { + if strings.Contains(a, "--machine=") { + t.Fatalf("the account's own manager was asked while it was not running: %q", a) + } + if a != "getent passwd operator" && a != "systemctl is-active user@1000.service" { + t.Errorf("asked something but whether the manager runs: %q", a) + } + } + // Logged in: read in the account's own manager, as before. + manager, asked = "active", nil + c.now = t0.Add(5 * time.Minute) + st, _ = j.Look(t.Context()) + if s := stateOf(t, st, "dunst.daemon"); s.State == Unknown || s.State == Unhealthy { + t.Fatalf("a unit in a running manager is read: %+v", s) + } + if last := asked[len(asked)-1]; !strings.HasPrefix(last, "systemctl --user --machine=operator@ show ") { + t.Fatalf("the running manager is read in the account's own: %q", last) + } +} diff --git a/internal/liveness/runtime.go b/internal/liveness/runtime.go index eccdc5a..93d101a 100644 --- a/internal/liveness/runtime.go +++ b/internal/liveness/runtime.go @@ -8,6 +8,8 @@ import ( "strconv" "strings" "sync" + + "github.com/novox/mesh-host/internal/system" ) // Runner runs a command and answers what it printed — the apply's own (apply.ExecRunner), so a look @@ -16,9 +18,13 @@ type Runner func(ctx context.Context, name string, args ...string) (string, erro // Exec reads the container runtime and the service manager through their command lines: one inspect of // every container, one show of every unit per manager. **Reads only** — the whole of what this file may -// ask either of them is `container inspect`, `version` and `show` (ADR 0240 rule 6, and a test holds it). +// ask either of them is `container inspect`, `version` and `show`, and of the machine's manager whether an +// account's own is active (ADR 0240 rule 6, and a test holds it). type Exec struct { Run Runner + // System is what the host was built for: it says how the machine's manager is asked whether an + // account's own runs (system.ManagerRunning). + System string mu sync.Mutex // cri is the runtime that answered, once one has. @@ -146,6 +152,19 @@ func (e *Exec) Units(ctx context.Context, scope, user string, units []string) (m } args := []string{"show", "--property=" + unitProperties, "--"} if scope == "user" && user != "" { + // **A look starts no account's manager** (novox/hq issue 764, ADR 0177): reading it is a login, + // which starts it when none runs. The machine's manager is asked first; a manager not running is + // away — its units unread, said unknown with why, never down — as the apply says them waiting. + running, exists, err := system.ManagerRunning(ctx, e.System, system.Runner(e.Run), user) + switch { + case err != nil: + return nil, fmt.Errorf("whether %s's own service manager runs could not be read: %w", user, err) + case !exists: + return nil, fmt.Errorf("the machine has no account %q, whose own manager these units are in", user) + case !running: + return nil, fmt.Errorf("%s's own service manager is not running: the account is not logged in "+ + "and does not linger", user) + } args = append([]string{"--user", "--machine=" + user + "@"}, args...) } said, err := e.Run(ctx, "systemctl", append(args, units...)...) diff --git a/internal/system/system.go b/internal/system/system.go index e0e8435..e361dff 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -304,6 +304,27 @@ func For(name string) (System, error) { name, strings.Join(names, ", ")) } +// userManagers is a service manager that runs a manager per account (novox/hq ADR 0177). +type userManagers interface { + UserManagerRunning(ctx context.Context, run Runner, account string) (running, exists bool, err error) +} + +// ManagerRunning is whether account's own manager runs on a machine built for name, asked of the +// machine's manager through run — never of the account's, which a question would start (see +// arch.UserManagerRunning). A look asks it before it reads anything in an account's manager (novox/hq +// issue 764). A system with no manager per account is an error: nothing can be read there. +func ManagerRunning(ctx context.Context, name string, run Runner, account string) (running, exists bool, err error) { + sys, err := For(name) + if err != nil { + return false, false, err + } + um, ok := sys.(userManagers) + if !ok { + return false, false, fmt.Errorf("this machine's service manager (%s) has no manager per account", sys.Name()) + } + return um.UserManagerRunning(ctx, run, account) +} + // All is every system the host can be built for. func All() []System { return []System{arch{}, alpine{}, android{}} diff --git a/internal/units/exec.go b/internal/units/exec.go index 8d8f73e..91c29a8 100644 --- a/internal/units/exec.go +++ b/internal/units/exec.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "strings" + + "github.com/novox/mesh-host/internal/system" ) // Runner runs a command and answers what it printed — the apply's own (apply.ExecRunner), so a look reads @@ -12,8 +14,9 @@ import ( type Runner func(ctx context.Context, name string, args ...string) (string, error) // Exec reads the service managers through systemctl and the package database through the system's own -// owner query. **Reads only**: `list-units`, `show` and the owner query are the whole of what it asks -// (ADR 0240 rule 6, and a test holds it). +// owner query. **Reads only**: `list-units`, `show`, the owner query, and — of the machine's manager and +// user database — whether an account's manager is active, are the whole of what it asks (ADR 0240 rule 6, +// and a test holds it). type Exec struct { Run Runner // System is what the host was built for; it says how a file's package is asked. A system with no @@ -103,3 +106,10 @@ func (e Exec) PackageOwning(ctx context.Context, path string) (string, bool, err } return "", false, nil } + +// UserManagerRunning asks the machine's manager whether an account's own runs, as the apply does before it +// touches a user-scoped unit (system.UserManagerRunning; novox/hq ADR 0177, issue 764). A system with no +// manager per account cannot say, and is an error: that manager is said unread. +func (e Exec) UserManagerRunning(ctx context.Context, account string) (running, exists bool, err error) { + return system.ManagerRunning(ctx, e.System, system.Runner(e.Run), account) +} diff --git a/internal/units/units.go b/internal/units/units.go index f796b5f..3abb2be 100644 --- a/internal/units/units.go +++ b/internal/units/units.go @@ -9,8 +9,8 @@ // own mount table, and a unit a removed package left behind, failed beside it, said by nobody. // // On every look the engine asks each service manager the mesh places units in — the machine's, and the -// account manager of every account the declaration names — which units failed, and says each one's -// owner: +// account manager of every account the declaration places a user-scoped unit for — which units failed, +// and says each one's owner: // // 1. a service or process the declaration states, by its unit and manager; // 2. a file the declaration writes, when the unit's file is that file; @@ -23,6 +23,12 @@ // // **It reads; it never acts** (ADR 0240 rule 6): `list-units`, `show` and the package manager's owner // query are the whole of what it asks. It neither resets nor restarts anything. +// +// **A look starts no account's manager** (novox/hq issue 764, ADR 0177). Reading an account's manager is +// a login, and a login starts the manager when none runs; so the machine's own manager is asked first +// whether user@.service is active, and the account's is read only then. A manager that is not +// running is away: no unit in it is failed, none is read, and the statement says it away — the apply's +// word for the same absence ("waiting" for its manager). package units import ( @@ -72,7 +78,9 @@ type Owned struct { // Files is keyed by path; Packages by package name, only those declared present. Files map[string]owner Packages map[string]owner - // Accounts are the accounts whose own managers are read, sorted. + // Accounts are the accounts whose own managers are read, sorted: each one the declaration places a + // user-scoped unit for, and no other (novox/hq issue 764). An account the declaration only creates + // has no unit of the mesh's in its manager to fail, and asking that manager starts it. Accounts []string } @@ -123,8 +131,6 @@ func OwnedBy(d *declaration.Declaration, held map[string]bool) Owned { if !v.Absent { o.Packages[v.Package] = own } - case *declaration.User: - accounts[v.Name] = true } } for a := range accounts { @@ -169,6 +175,10 @@ type Reader interface { Show(ctx context.Context, scope, user string, units []string) (map[string]Shown, error) // PackageOwning is the package a file belongs to; false when none does or the machine cannot say. PackageOwning(ctx context.Context, path string) (string, bool, error) + // UserManagerRunning is whether an account's own manager runs now, asked of the machine's manager + // and never of the account's, which a question would start (novox/hq issue 764); exists is false + // for an account the machine does not have. + UserManagerRunning(ctx context.Context, account string) (running, exists bool, err error) } // Failed is one failed unit as the statement says it. @@ -198,6 +208,9 @@ type Statement struct { Failed []Failed // Unread names each manager that could not be read, with why. Unread []string + // Away names each account manager not running, so not read: its account is not logged in and does + // not linger. Nothing in it is failed (novox/hq issue 764). + Away []string } // Owned answers the failures a module owns; Unowned those no module does. @@ -290,16 +303,39 @@ func (j *Judge) Look(ctx context.Context) (Statement, bool) { st := Statement{At: now, State: Unknown} read := 0 failedNow := map[string]bool{} + keep := func(m manager) { + // What it held is neither cleared nor counted while it cannot be read. + for k := range j.seen { + if strings.HasPrefix(k, key(m.scope, m.user, "")) { + failedNow[k] = true + } + } + } for _, m := range managers { + if m.scope == ScopeUser { + // Asked of the machine's manager first: reading the account's own would start it. + running, exists, err := j.reader.UserManagerRunning(ctx, m.user) + switch { + case err != nil: + st.Unread = append(st.Unread, fmt.Sprintf("%s could not be read: %s", m, firstLine(err.Error()))) + keep(m) + continue + case !exists: + st.Unread = append(st.Unread, fmt.Sprintf("%s could not be read: the machine has no account %q", + m, m.user)) + keep(m) + continue + case !running: + // Away: nothing in it runs, so nothing in it is failed; what it held is cleared. + st.Away = append(st.Away, m.user+"'s own service manager is not running: the account is not "+ + "logged in and does not linger, so its units are not read") + continue + } + } listed, err := j.reader.Failed(ctx, m.scope, m.user) if err != nil { st.Unread = append(st.Unread, fmt.Sprintf("%s could not be read: %s", m, firstLine(err.Error()))) - // What it held is neither cleared nor counted while it cannot be read. - for k := range j.seen { - if strings.HasPrefix(k, key(m.scope, m.user, "")) { - failedNow[k] = true - } - } + keep(m) continue } read++ @@ -353,7 +389,7 @@ func (j *Judge) Look(ctx context.Context) (Statement, bool) { if read == 0 { st.State = Unknown } - word := st.State + word := st.State + "|away:" + strings.Join(st.Away, ",") for _, f := range st.Failed { word += "|" + f.Scope + "/" + f.Unit + "/" + f.Module } diff --git a/internal/units/units_test.go b/internal/units/units_test.go index 71f7cf5..e5b6adb 100644 --- a/internal/units/units_test.go +++ b/internal/units/units_test.go @@ -21,6 +21,11 @@ type fakeReader struct { owners map[string]string // by path unread map[string]error ownerQs int + // away is each account whose own manager is not running; noAccount each the machine lacks. + away map[string]bool + noAccount map[string]bool + // read is every manager whose failed units were asked, in order. + read []string } func mgr(scope, user string) string { @@ -31,6 +36,7 @@ func mgr(scope, user string) string { } func (f *fakeReader) Failed(_ context.Context, scope, user string) ([]Listed, error) { + f.read = append(f.read, mgr(scope, user)) if err := f.unread[mgr(scope, user)]; err != nil { return nil, err } @@ -45,6 +51,13 @@ func (f *fakeReader) Show(_ context.Context, _, _ string, units []string) (map[s return out, nil } +func (f *fakeReader) UserManagerRunning(_ context.Context, account string) (bool, bool, error) { + if f.noAccount[account] { + return false, false, nil + } + return !f.away[account], true, nil +} + func (f *fakeReader) PackageOwning(_ context.Context, path string) (string, bool, error) { f.ownerQs++ p, ok := f.owners[path] @@ -75,6 +88,11 @@ func declared() *declaration.Declaration { &declaration.Package{ID: "openrazer.daemon", Type: declaration.TypePackage, Package: "openrazer-daemon"}, &declaration.Package{ID: "clipmenu.greenclip", Type: declaration.TypePackage, Package: "rofi-greenclip", Absent: true}, &declaration.User{ID: "zsh.account", Type: declaration.TypeUser, Name: "operator"}, + // The operator's own manager is read because a module places a unit in it (dunst, on a desktop). + &declaration.Service{ID: "dunst.reload", Type: declaration.TypeService, Unit: "dunst.service", + Scope: declaration.ScopeUser, User: "operator"}, + // A module account the declaration only creates: its manager is never asked (issue 764). + &declaration.User{ID: "telegram.account", Type: declaration.TypeUser, Name: "telegram"}, &declaration.Service{ID: "sshd.run", Type: declaration.TypeService, Unit: "sshd.service", State: "running"}, &declaration.Service{ID: "power.after-boot", Type: declaration.TypeService, Unit: "mesh-power-after-boot.service"}, &declaration.File{ID: "watcher.unit", Type: declaration.TypeFile, Path: "/home/operator/.config/systemd/user/watcher.service"}, @@ -283,3 +301,102 @@ func TestNothingIsReadBeforeADeclaration(t *testing.T) { t.Fatalf("before a declaration every unit would read as the machine's: %+v", st) } } + +// Issue 764: a look starts no account's manager. Only an account the declaration places a user-scoped +// unit for is read at all, and its manager only while the machine's says it runs. + +func TestAnAccountWithNoUserUnitsIsNeverRead(t *testing.T) { + o := OwnedBy(declared(), nil) + if strings.Join(o.Accounts, " ") != "operator" { + t.Fatalf("only an account with a user-scoped unit is read: %v", o.Accounts) + } + r := shanks() + j := New(r) + j.Set(o) + lookTwice(t, j) + for _, m := range r.read { + if m == "user:telegram" { + t.Fatalf("a module account the declaration only creates had its manager read: %v", r.read) + } + } +} + +func TestAManagerNotRunningIsAwayAndNotRead(t *testing.T) { + r := shanks() + j := New(r) + j.Set(OwnedBy(declared(), nil)) + lookTwice(t, j) + if len(j.Last().Owned()) != 1 { + t.Fatalf("logged in, the Razer daemon is said: %+v", j.Last().Failed) + } + // The operator logs out: the manager is not asked, nothing in it is failed, it is said away. + r.away = map[string]bool{"operator": true} + r.read = nil + st, changed := j.Look(context.Background()) + if strings.Join(r.read, " ") != "system" { + t.Fatalf("a manager not running is not read: %v", r.read) + } + if !changed || len(st.Away) != 1 || !strings.Contains(st.Away[0], "operator") || len(st.Unread) != 0 { + t.Fatalf("away, said, and not unread: %+v", st) + } + for _, f := range st.Failed { + if f.Scope == ScopeUser { + t.Fatalf("a manager not running has no failed units: %+v", f) + } + } + if st.State != Degraded { + t.Fatalf("the machine's own failed mounts still say degraded: %q", st.State) + } + // Back in: read as before, and a failure is new again (two looks). + r.away = nil + st, _ = j.Look(context.Background()) + if len(st.Away) != 0 || len(st.Owned()) != 0 { + t.Fatalf("a manager running again is read, its failure on its first look not yet said: %+v", st) + } + st, _ = j.Look(context.Background()) + if len(st.Owned()) != 1 { + t.Fatalf("and said on the second: %+v", st.Failed) + } +} + +func TestAnAccountTheMachineLacksIsUnread(t *testing.T) { + r := shanks() + r.noAccount = map[string]bool{"operator": true} + j := New(r) + j.Set(OwnedBy(declared(), nil)) + st, _ := j.Look(context.Background()) + if strings.Join(r.read, " ") != "system" || len(st.Unread) != 1 { + t.Fatalf("an account the machine lacks is unread and its manager never asked: %v %+v", r.read, st) + } +} + +func TestWhetherAManagerRunsIsAskedOfTheMachines(t *testing.T) { + var asked []string + state := "inactive" + run := func(_ context.Context, name string, args ...string) (string, error) { + asked = append(asked, name+" "+strings.Join(args, " ")) + switch name { + case "getent": + return "telegram:x:10007:10007::/var/lib/telegram:/usr/bin/nologin\n", nil + case "systemctl": + if state != "active" { + return state + "\n", errors.New("systemctl exited 3") + } + return state + "\n", nil + } + return "", errors.New("unexpected") + } + e := Exec{Run: run, System: "arch"} + if running, exists, err := e.UserManagerRunning(context.Background(), "telegram"); running || !exists || err != nil { + t.Fatalf("an inactive manager is not running: %v %v %v", running, exists, err) + } + state = "active" + if running, _, err := e.UserManagerRunning(context.Background(), "telegram"); !running || err != nil { + t.Fatalf("an active manager runs: %v %v", running, err) + } + for _, a := range asked { + if a != "getent passwd telegram" && a != "systemctl is-active user@10007.service" { + t.Errorf("asked something but the user database and the machine's manager: %q", a) + } + } +}