Land feature 57's node-engine work on its release branch
mesh/merge-gate head 5a7ec3775b98 merged into trunk 53fd32618916 beside mesh-controller#346@c711e622 — pass: builds mesh-host → ace, g14, novox, shanks; no…
mesh/repo-check head 5a7ec3775b98 merged into trunk 53fd32618916 beside mesh-controller#346@c711e622 — pass: [release feat/57-config-and-provisioning, judg…
mesh/delivery ready: it delivers once merged
mesh/delivery-group group 57-config-and-provisioning rejected: composed together: [release feat/57-config-and-provisioning, judged by its controller fcdecb4361…
mesh/merge-gate head 5a7ec3775b98 merged into trunk 53fd32618916 beside mesh-controller#346@c711e622 — pass: builds mesh-host → ace, g14, novox, shanks; no…
mesh/repo-check head 5a7ec3775b98 merged into trunk 53fd32618916 beside mesh-controller#346@c711e622 — pass: [release feat/57-config-and-provisioning, judg…
mesh/delivery ready: it delivers once merged
mesh/delivery-group group 57-config-and-provisioning rejected: composed together: [release feat/57-config-and-provisioning, judged by its controller fcdecb4361…
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).
This commit is contained in:
+19
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
+24
-2
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
}
|
||||
|
||||
@@ -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")))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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+
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.<seat>.call.<verb>.<caller>`, 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
|
||||
}
|
||||
@@ -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~<machine>` 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)
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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...)...)
|
||||
|
||||
@@ -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{}}
|
||||
|
||||
+12
-2
@@ -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)
|
||||
}
|
||||
|
||||
+48
-12
@@ -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@<uid>.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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user